diff --git a/CHANGES.rst b/CHANGES.rst index b9b3e9d0e..ec53438f5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,22 @@ +Changes in synapse 0.2.3 (2014-09-12) +===================================== + +Homeserver: + * Fix bug where we stopped sending events to remote home servers if a + user from that home server left, even if there were some still in the + room. + * Fix bugs in the state conflict resolution where it was incorrectly + rejecting events. + +Webclient: + * Display room names and topics. + * Allow setting/editing of room names and topics. + * Display information about rooms on the main page. + * Handle ban and kick events in real time. + * VoIP UI and reliabilty improvements. + * Improvements to initial startup speed. + * Don't display duplicate join events. + Changes in synapse 0.2.2 (2014-09-06) ===================================== diff --git a/VERSION b/VERSION index ee1372d33..717903969 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.2 +0.2.3 diff --git a/docs/client-server/howto.rst b/docs/client-server/howto.rst index c02ea8d89..ec941eddd 100644 --- a/docs/client-server/howto.rst +++ b/docs/client-server/howto.rst @@ -24,7 +24,7 @@ If you already have an account, you must **login** into it. `Try out the fiddle`__ -.. __: http://jsfiddle.net/4q2jyxng/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/register_login Registration ------------ @@ -87,7 +87,7 @@ user and **send a message** to that room. `Try out the fiddle`__ -.. __: http://jsfiddle.net/zL3zto9g/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/create_room_send_msg Creating a room --------------- @@ -137,7 +137,7 @@ join a room **via a room alias** if one was set up. `Try out the fiddle`__ -.. __: http://jsfiddle.net/7fhotf1b/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/room_memberships Inviting a user to a room ------------------------- @@ -183,7 +183,7 @@ of getting events, depending on what the client already knows. `Try out the fiddle`__ -.. __: http://jsfiddle.net/vw11mg37/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/event_stream Getting all state ----------------- @@ -633,4 +633,4 @@ application. `Try out the fiddle`__ -.. __: http://jsfiddle.net/uztL3yme/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/example_app diff --git a/docs/model/presence.rst b/docs/client-server/model/presence.rst similarity index 100% rename from docs/model/presence.rst rename to docs/client-server/model/presence.rst diff --git a/docs/model/profiles.rst b/docs/client-server/model/profiles.rst similarity index 100% rename from docs/model/profiles.rst rename to docs/client-server/model/profiles.rst diff --git a/docs/model/protocol_examples.rst b/docs/client-server/model/protocol_examples.rst similarity index 100% rename from docs/model/protocol_examples.rst rename to docs/client-server/model/protocol_examples.rst diff --git a/docs/model/room-join-workflow.rst b/docs/client-server/model/room-join-workflow.rst similarity index 100% rename from docs/model/room-join-workflow.rst rename to docs/client-server/model/room-join-workflow.rst diff --git a/docs/model/rooms.rst b/docs/client-server/model/rooms.rst similarity index 100% rename from docs/model/rooms.rst rename to docs/client-server/model/rooms.rst diff --git a/docs/model/terminology.rst b/docs/client-server/model/terminology.rst similarity index 100% rename from docs/model/terminology.rst rename to docs/client-server/model/terminology.rst diff --git a/docs/model/third-party-id.rst b/docs/client-server/model/third-party-id.rst similarity index 100% rename from docs/model/third-party-id.rst rename to docs/client-server/model/third-party-id.rst diff --git a/docs/human-id-rules.rst b/docs/human-id-rules.rst new file mode 100644 index 000000000..3a1ff3989 --- /dev/null +++ b/docs/human-id-rules.rst @@ -0,0 +1,79 @@ +This document outlines the format for human-readable IDs within matrix. + +Overview +-------- +UTF-8 is quickly becoming the standard character encoding set on the web. As +such, Matrix requires that all strings MUST be encoded as UTF-8. However, +using Unicode as the character set for human-readable IDs is troublesome. There +are many different characters which appear identical to each other, but would +identify different users. In addition, there are non-printable characters which +cannot be rendered by the end-user. This opens up a security vulnerability with +phishing/spoofing of IDs, commonly known as a homograph attack. + +Web browers encountered this problem when International Domain Names were +introduced. A variety of checks were put in place in order to protect users. If +an address failed the check, the raw punycode would be displayed to disambiguate +the address. Similar checks are performed by home servers in Matrix. However, +Matrix does not use punycode representations, and so does not show raw punycode +on a failed check. Instead, home servers must outright reject these misleading +IDs. + +Types of human-readable IDs +--------------------------- +There are two main human-readable IDs in question: + +- Room aliases +- User IDs + +Room aliases look like ``#localpart:domain``. These aliases point to opaque +non human-readable room IDs. These pointers can change, so there is already an +issue present with the same ID pointing to a different destination at a later +date. + +User IDs look like ``@localpart:domain``. These represent actual end-users, and +unlike room aliases, there is no layer of indirection. This presents a much +greater concern with homograph attacks. + +Checks +------ +- Similar to web browsers. +- blacklisted chars (e.g. non-printable characters) +- mix of language sets from 'preferred' language not allowed. +- Language sets from CLDR dataset. +- Treated in segments (localpart, domain) +- Additional restrictions for ease of processing IDs. + - Room alias localparts MUST NOT have ``#`` or ``:``. + - User ID localparts MUST NOT have ``@`` or ``:``. + +Rejecting +--------- +- Home servers MUST reject room aliases which do not pass the check, both on + GETs and PUTs. +- Home servers MUST reject user ID localparts which do not pass the check, both + on creation and on events. +- Any home server whose domain does not pass this check, MUST use their punycode + domain name instead of the IDN, to prevent other home servers rejecting you. +- Error code is ``M_FAILED_HUMAN_ID_CHECK``. (generic enough for both failing + due to homograph attacks, and failing due to including ``:`` s, etc) +- Error message MAY go into further information about which characters were + rejected and why. +- Error message SHOULD contain a ``failed_keys`` key which contains an array + of strings which represent the keys which failed the check e.g:: + + failed_keys: [ user_id, room_alias ] + +Other considerations +-------------------- +- Basic security: Informational key on the event attached by HS to say "unsafe + ID". Problem: clients can just ignore it, and since it will appear only very + rarely, easy to forget when implementing clients. +- Moderate security: Requires client handshake. Forces clients to implement + a check, else they cannot communicate with the misleading ID. However, this is + extra overhead in both client implementations and round-trips. +- High security: Outright rejection of the ID at the point of creation / + receiving event. Point of creation rejection is preferable to avoid the ID + entering the system in the first place. However, malicious HSes can just allow + the ID. Hence, other home servers must reject them if they see them in events. + Client never sees the problem ID, provided the HS is correctly implemented. +- High security decided; client doesn't need to worry about it, no additional + protocol complexity aside from rejection of an event. \ No newline at end of file diff --git a/docs/specification.rst b/docs/specification.rst index b15792c00..aad200c04 100644 --- a/docs/specification.rst +++ b/docs/specification.rst @@ -1249,7 +1249,33 @@ Or a rejected call: <------- m.call.hangup Calls are negotiated according to the WebRTC specification. - + + +Glare +----- +This specification aims to address the problem of two users calling each other +at roughly the same time and their invites crossing on the wire. It is a far +better experience for the users if their calls are connected if it is clear +that their intention is to set up a call with one another. + +In Matrix, calls are to rooms rather than users (even if those rooms may only +contain one other user) so we consider calls which are to the same room. + +The rules for dealing with such a situation are as follows: + + - If an invite to a room is received whilst the client is preparing to send an + invite to the same room, the client should cancel its outgoing call and + instead automatically accept the incoming call on behalf of the user. + - If an invite to a room is received after the client has sent an invite to the + same room and is waiting for a response, the client should perform a + lexicographical comparison of the call IDs of the two calls and use the + lesser of the two calls, aborting the greater. If the incoming call is the + lesser, the client should accept this call on behalf of the user. + +The call setup should appear seamless to the user as if they had simply placed +a call and the other party had accepted. Thusly, any media stream that had been +setup for use on a call should be transferred and used for the call that +replaces it. Profiles ======== diff --git a/jsfiddles/create_room_send_msg/demo.js b/jsfiddles/create_room_send_msg/demo.js index 3dc726383..9c346e2f6 100644 --- a/jsfiddles/create_room_send_msg/demo.js +++ b/jsfiddles/create_room_send_msg/demo.js @@ -19,7 +19,12 @@ $('.login').live('click', function() { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }); diff --git a/jsfiddles/event_stream/demo.js b/jsfiddles/event_stream/demo.js index 5c81e08ca..acba8391f 100644 --- a/jsfiddles/event_stream/demo.js +++ b/jsfiddles/event_stream/demo.js @@ -58,7 +58,12 @@ $('.login').live('click', function() { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }); diff --git a/jsfiddles/example_app/demo.details b/jsfiddles/example_app/demo.details new file mode 100644 index 000000000..3f96d3e74 --- /dev/null +++ b/jsfiddles/example_app/demo.details @@ -0,0 +1,7 @@ + name: Example Matrix Client + description: Includes login, live event streaming, creating rooms, sending messages and viewing member lists. + authors: + - matrix.org + resources: + - http://matrix.org + normalize_css: no \ No newline at end of file diff --git a/jsfiddles/register_login/demo.js b/jsfiddles/register_login/demo.js index 959503917..fffa9e055 100644 --- a/jsfiddles/register_login/demo.js +++ b/jsfiddles/register_login/demo.js @@ -20,7 +20,12 @@ $('.register').live('click', function() { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }); @@ -36,7 +41,12 @@ var login = function(user, password) { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }; diff --git a/jsfiddles/room_memberships/demo.js b/jsfiddles/room_memberships/demo.js index 64ba76713..8a7b1aa88 100644 --- a/jsfiddles/room_memberships/demo.js +++ b/jsfiddles/room_memberships/demo.js @@ -28,7 +28,12 @@ $('.login').live('click', function() { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }); diff --git a/synapse/__init__.py b/synapse/__init__.py index 1ed9cdcdf..d60267ebe 100644 --- a/synapse/__init__.py +++ b/synapse/__init__.py @@ -16,4 +16,4 @@ """ This is a reference implementation of a synapse home server. """ -__version__ = "0.2.2" +__version__ = "0.2.3" diff --git a/synapse/federation/units.py b/synapse/federation/units.py index 974043127..622fe66a8 100644 --- a/synapse/federation/units.py +++ b/synapse/federation/units.py @@ -69,6 +69,7 @@ class Pdu(JsonEncodedObject): "prev_state_id", "prev_state_origin", "required_power_level", + "user_id", ] internal_keys = [ diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index a0d0f2af1..310cb46fe 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -593,6 +593,12 @@ class RoomListHandler(BaseHandler): @defer.inlineCallbacks def get_public_room_list(self): chunk = yield self.store.get_rooms(is_public=True) + for room in chunk: + joined_members = yield self.store.get_room_members( + room_id=room["room_id"], + membership=Membership.JOIN + ) + room["num_joined_members"] = len(joined_members) # FIXME (erikj): START is no longer a valid value defer.returnValue({"start": "START", "end": "END", "chunk": chunk}) diff --git a/synapse/state.py b/synapse/state.py index 5dcff2736..9db84c9b5 100644 --- a/synapse/state.py +++ b/synapse/state.py @@ -115,6 +115,8 @@ class StateHandler(object): is_new = yield self._handle_new_state(new_pdu) + logger.debug("is_new: %s %s %s", is_new, new_pdu.pdu_id, new_pdu.origin) + if is_new: yield self.store.update_current_state( pdu_id=new_pdu.pdu_id, @@ -134,7 +136,9 @@ class StateHandler(object): @defer.inlineCallbacks @log_function def _handle_new_state(self, new_pdu): - tree = yield self.store.get_unresolved_state_tree(new_pdu) + tree, missing_branch = yield self.store.get_unresolved_state_tree( + new_pdu + ) new_branch, current_branch = tree logger.debug( @@ -142,64 +146,17 @@ class StateHandler(object): new_branch, current_branch ) - if not current_branch: - # There is no current state - defer.returnValue(True) - return - - n = new_branch[-1] - c = current_branch[-1] - - if n.pdu_id == c.pdu_id and n.origin == c.origin: - # We have all the PDUs we need, so we can just do the conflict - # resolution. - - if len(current_branch) == 1: - # This is a direct clobber so we can just... - defer.returnValue(True) - - conflict_res = [ - self._do_power_level_conflict_res, - self._do_chain_length_conflict_res, - self._do_hash_conflict_res, - ] - - for algo in conflict_res: - new_res, curr_res = algo(new_branch, current_branch) - - if new_res < curr_res: - defer.returnValue(False) - elif new_res > curr_res: - defer.returnValue(True) - - raise Exception("Conflict resolution failed.") - - else: - # We need to ask for PDUs. - missing_prev = max( - new_branch[-1], current_branch[-1], - key=lambda x: x.depth - ) - - if not hasattr(missing_prev, "prev_state_id"): - # FIXME Hmm - # temporary fallback - for algo in conflict_res: - new_res, curr_res = algo(new_branch, current_branch) - - if new_res < curr_res: - defer.returnValue(False) - elif new_res > curr_res: - defer.returnValue(True) - return + if missing_branch is not None: + # We're missing some PDUs. Fetch them. + # TODO (erikj): Limit this. + missing_prev = tree[missing_branch][-1] pdu_id = missing_prev.prev_state_id origin = missing_prev.prev_state_origin is_missing = yield self.store.get_pdu(pdu_id, origin) is None - if not is_missing: - raise Exception("Conflict resolution failed.") + raise Exception("Conflict resolution failed") yield self._replication.get_pdu( destination=missing_prev.origin, @@ -211,23 +168,93 @@ class StateHandler(object): updated_current = yield self._handle_new_state(new_pdu) defer.returnValue(updated_current) - def _do_power_level_conflict_res(self, new_branch, current_branch): - max_power_new = max( - new_branch[:-1], - key=lambda t: t.power_level - ).power_level + if not current_branch: + # There is no current state + defer.returnValue(True) + return - max_power_current = max( - current_branch[:-1], - key=lambda t: t.power_level - ).power_level + n = new_branch[-1] + c = current_branch[-1] - return (max_power_new, max_power_current) + common_ancestor = n.pdu_id == c.pdu_id and n.origin == c.origin - def _do_chain_length_conflict_res(self, new_branch, current_branch): + if common_ancestor: + # We found a common ancestor! + + if len(current_branch) == 1: + # This is a direct clobber so we can just... + defer.returnValue(True) + + else: + # We didn't find a common ancestor. This is probably fine. + pass + + result = yield self._do_conflict_res( + new_branch, current_branch, common_ancestor + ) + defer.returnValue(result) + + @defer.inlineCallbacks + def _do_conflict_res(self, new_branch, current_branch, common_ancestor): + conflict_res = [ + self._do_power_level_conflict_res, + self._do_chain_length_conflict_res, + self._do_hash_conflict_res, + ] + + for algo in conflict_res: + new_res, curr_res = yield defer.maybeDeferred( + algo, + new_branch, current_branch, common_ancestor + ) + + if new_res < curr_res: + defer.returnValue(False) + elif new_res > curr_res: + defer.returnValue(True) + + raise Exception("Conflict resolution failed.") + + @defer.inlineCallbacks + def _do_power_level_conflict_res(self, new_branch, current_branch, + common_ancestor): + new_powers_deferreds = [] + for e in new_branch[:-1] if common_ancestor else new_branch: + if hasattr(e, "user_id"): + new_powers_deferreds.append( + self.store.get_power_level(e.context, e.user_id) + ) + + current_powers_deferreds = [] + for e in current_branch[:-1] if common_ancestor else current_branch: + if hasattr(e, "user_id"): + current_powers_deferreds.append( + self.store.get_power_level(e.context, e.user_id) + ) + + new_powers = yield defer.gatherResults( + new_powers_deferreds, + consumeErrors=True + ) + + current_powers = yield defer.gatherResults( + current_powers_deferreds, + consumeErrors=True + ) + + max_power_new = max(new_powers) + max_power_current = max(current_powers) + + defer.returnValue( + (max_power_new, max_power_current) + ) + + def _do_chain_length_conflict_res(self, new_branch, current_branch, + common_ancestor): return (len(new_branch), len(current_branch)) - def _do_hash_conflict_res(self, new_branch, current_branch): + def _do_hash_conflict_res(self, new_branch, current_branch, + common_ancestor): new_str = "".join([p.pdu_id + p.origin for p in new_branch]) c_str = "".join([p.pdu_id + p.origin for p in current_branch]) diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py index 81c3c94b2..9201a377b 100644 --- a/synapse/storage/__init__.py +++ b/synapse/storage/__init__.py @@ -36,7 +36,7 @@ from .registration import RegistrationStore from .room import RoomStore from .roommember import RoomMemberStore from .stream import StreamStore -from .pdu import StatePduStore, PduStore +from .pdu import StatePduStore, PduStore, PdusTable from .transactions import TransactionStore from .keys import KeyStore @@ -47,6 +47,11 @@ import os logger = logging.getLogger(__name__) +class _RollbackButIsFineException(Exception): + """ This exception is used to rollback a transaction without implying + something went wrong. + """ + pass class DataStore(RoomMemberStore, RoomStore, RegistrationStore, StreamStore, ProfileStore, FeedbackStore, @@ -71,14 +76,16 @@ class DataStore(RoomMemberStore, RoomStore, self.min_token -= 1 stream_ordering = self.min_token - latest = yield self._db_pool.runInteraction( - self._persist_pdu_event_txn, - pdu=pdu, - event=event, - backfilled=backfilled, - stream_ordering=stream_ordering, - ) - defer.returnValue(latest) + try: + yield self._db_pool.runInteraction( + self._persist_pdu_event_txn, + pdu=pdu, + event=event, + backfilled=backfilled, + stream_ordering=stream_ordering, + ) + except _RollbackButIsFineException as e: + pass @defer.inlineCallbacks def get_event(self, event_id, allow_none=False): @@ -116,6 +123,12 @@ class DataStore(RoomMemberStore, RoomStore, del cols["content"] del cols["prev_pdus"] cols["content_json"] = json.dumps(pdu.content) + + unrec_keys.update({ + k: v for k, v in cols.items() + if k not in PdusTable.fields + }) + cols["unrecognized_keys"] = json.dumps(unrec_keys) logger.debug("Persisting: %s", repr(cols)) @@ -175,11 +188,12 @@ class DataStore(RoomMemberStore, RoomStore, try: self._simple_insert_txn(txn, "events", vals) except: - logger.exception( + logger.warn( "Failed to persist, probably duplicate: %s", - event.event_id + event.event_id, + exc_info=True, ) - return + raise _RollbackButIsFineException("_persist_event") if not backfilled and hasattr(event, "state_key"): vals = { @@ -205,8 +219,6 @@ class DataStore(RoomMemberStore, RoomStore, } ) - return self._get_room_events_max_id_txn(txn) - @defer.inlineCallbacks def get_current_state(self, room_id, event_type=None, state_key=""): sql = ( diff --git a/synapse/storage/_base.py b/synapse/storage/_base.py index 803722507..8deaaf93b 100644 --- a/synapse/storage/_base.py +++ b/synapse/storage/_base.py @@ -17,6 +17,7 @@ import logging from twisted.internet import defer from synapse.api.errors import StoreError +from synapse.util.logutils import log_function import collections import copy @@ -91,6 +92,7 @@ class SQLBaseStore(object): self._simple_insert_txn, table, values, or_replace=or_replace ) + @log_function def _simple_insert_txn(self, txn, table, values, or_replace=False): sql = "%s INTO %s (%s) VALUES(%s)" % ( ("INSERT OR REPLACE" if or_replace else "INSERT"), @@ -98,6 +100,12 @@ class SQLBaseStore(object): ", ".join(k for k in values), ", ".join("?" for k in values) ) + + logger.debug( + "[SQL] %s Args=%s Func=%s", + sql, values.values(), + ) + txn.execute(sql, values.values()) return txn.lastrowid diff --git a/synapse/storage/pdu.py b/synapse/storage/pdu.py index 0bf97e37e..3c859fdea 100644 --- a/synapse/storage/pdu.py +++ b/synapse/storage/pdu.py @@ -17,6 +17,7 @@ from twisted.internet import defer from ._base import SQLBaseStore, Table, JoinHelper +from synapse.federation.units import Pdu from synapse.util.logutils import log_function from collections import namedtuple @@ -308,8 +309,8 @@ class PduStore(SQLBaseStore): @defer.inlineCallbacks def get_oldest_pdus_in_context(self, context): - """Get a list of Pdus that we haven't backfilled beyond yet (and haven't - seen). This list is used when we want to backfill backwards and is the + """Get a list of Pdus that we haven't backfilled beyond yet (and havent + seen). This list is used when we want to backfill backwards and is the list we send to the remote server. Args: @@ -516,7 +517,7 @@ class StatePduStore(SQLBaseStore): if not current: logger.debug("get_unresolved_state_tree No current state.") - return return_value + return (return_value, None) return_value.current_branch.append(current) @@ -524,13 +525,16 @@ class StatePduStore(SQLBaseStore): txn, new_pdu, current ) + missing_branch = None for branch, prev_state, state in enum_branches: if state: return_value[branch].append(state) else: + # We don't have prev_state :( + missing_branch = branch break - return return_value + return (return_value, missing_branch) def update_current_state(self, pdu_id, origin, context, pdu_type, state_key): @@ -622,53 +626,6 @@ class StatePduStore(SQLBaseStore): return result - def get_next_missing_pdu(self, new_pdu): - """When we get a new state pdu we need to check whether we need to do - any conflict resolution, if we do then we need to check if we need - to go back and request some more state pdus that we haven't seen yet. - - Args: - txn - new_pdu - - Returns: - PduIdTuple: A pdu that we are missing, or None if we have all the - pdus required to do the conflict resolution. - """ - return self._db_pool.runInteraction( - self._get_next_missing_pdu, new_pdu - ) - - def _get_next_missing_pdu(self, txn, new_pdu): - logger.debug( - "get_next_missing_pdu %s %s", - new_pdu.pdu_id, new_pdu.origin - ) - - current = self._get_current_interaction( - txn, - new_pdu.context, new_pdu.pdu_type, new_pdu.state_key - ) - - if (not current or not current.prev_state_id - or not current.prev_state_origin): - return None - - # Oh look, it's a straight clobber, so wooooo almost no-op. - if (new_pdu.prev_state_id == current.pdu_id - and new_pdu.prev_state_origin == current.origin): - return None - - enum_branches = self._enumerate_state_branches(txn, new_pdu, current) - for branch, prev_state, state in enum_branches: - if not state: - return PduIdTuple( - prev_state.prev_state_id, - prev_state.prev_state_origin - ) - - return None - def handle_new_state(self, new_pdu): """Actually perform conflict resolution on the new_pdu on the assumption we have all the pdus required to perform it. @@ -752,24 +709,11 @@ class StatePduStore(SQLBaseStore): return is_current - @classmethod @log_function - def _enumerate_state_branches(cls, txn, pdu_a, pdu_b): + def _enumerate_state_branches(self, txn, pdu_a, pdu_b): branch_a = pdu_a branch_b = pdu_b - get_query = ( - "SELECT %(fields)s FROM %(pdus)s as p " - "LEFT JOIN %(state)s as s " - "ON p.pdu_id = s.pdu_id AND p.origin = s.origin " - "WHERE p.pdu_id = ? AND p.origin = ? " - ) % { - "fields": _pdu_state_joiner.get_fields( - PdusTable="p", StatePdusTable="s"), - "pdus": PdusTable.table_name, - "state": StatePdusTable.table_name, - } - while True: if (branch_a.pdu_id == branch_b.pdu_id and branch_a.origin == branch_b.origin): @@ -801,13 +745,12 @@ class StatePduStore(SQLBaseStore): branch_a.prev_state_origin ) - logger.debug("getting branch_a prev %s", pdu_tuple) - txn.execute(get_query, pdu_tuple) - prev_branch = branch_a - res = txn.fetchone() - branch_a = PduEntry(*res) if res else None + logger.debug("getting branch_a prev %s", pdu_tuple) + branch_a = self._get_pdu_tuple(txn, *pdu_tuple) + if branch_a: + branch_a = Pdu.from_pdu_tuple(branch_a) logger.debug("branch_a=%s", branch_a) @@ -820,14 +763,13 @@ class StatePduStore(SQLBaseStore): branch_b.prev_state_id, branch_b.prev_state_origin ) - txn.execute(get_query, pdu_tuple) - - logger.debug("getting branch_b prev %s", pdu_tuple) prev_branch = branch_b - res = txn.fetchone() - branch_b = PduEntry(*res) if res else None + logger.debug("getting branch_b prev %s", pdu_tuple) + branch_b = self._get_pdu_tuple(txn, *pdu_tuple) + if branch_b: + branch_b = Pdu.from_pdu_tuple(branch_b) logger.debug("branch_b=%s", branch_b) diff --git a/synapse/storage/roommember.py b/synapse/storage/roommember.py index 9a393e256..676b2f265 100644 --- a/synapse/storage/roommember.py +++ b/synapse/storage/roommember.py @@ -18,6 +18,7 @@ from twisted.internet import defer from ._base import SQLBaseStore from synapse.api.constants import Membership +from synapse.util.logutils import log_function import logging @@ -29,8 +30,18 @@ class RoomMemberStore(SQLBaseStore): def _store_room_member_txn(self, txn, event): """Store a room member in the database. """ - target_user_id = event.state_key - domain = self.hs.parse_userid(target_user_id).domain + try: + target_user_id = event.state_key + domain = self.hs.parse_userid(target_user_id).domain + except: + logger.exception("Failed to parse target_user_id=%s", target_user_id) + raise + + logger.debug( + "_store_room_member_txn: target_user_id=%s, membership=%s", + target_user_id, + event.membership, + ) self._simple_insert_txn( txn, @@ -51,12 +62,30 @@ class RoomMemberStore(SQLBaseStore): "VALUES (?, ?)" ) txn.execute(sql, (event.room_id, domain)) - else: - sql = ( - "DELETE FROM room_hosts WHERE room_id = ? AND host = ?" + elif event.membership != Membership.INVITE: + # Check if this was the last person to have left. + member_events = self._get_members_query_txn( + txn, + where_clause="c.room_id = ? AND m.membership = ? AND m.user_id != ?", + where_values=(event.room_id, Membership.JOIN, target_user_id,) ) - txn.execute(sql, (event.room_id, domain)) + joined_domains = set() + for e in member_events: + try: + joined_domains.add( + self.hs.parse_userid(e.state_key).domain + ) + except: + # FIXME: How do we deal with invalid user ids in the db? + logger.exception("Invalid user_id: %s", event.state_key) + + if domain not in joined_domains: + sql = ( + "DELETE FROM room_hosts WHERE room_id = ? AND host = ?" + ) + + txn.execute(sql, (event.room_id, domain)) @defer.inlineCallbacks def get_room_member(self, user_id, room_id): @@ -146,8 +175,13 @@ class RoomMemberStore(SQLBaseStore): vals = where_dict.values() return self._get_members_query(clause, vals) - @defer.inlineCallbacks def _get_members_query(self, where_clause, where_values): + return self._db_pool.runInteraction( + self._get_members_query_txn, + where_clause, where_values + ) + + def _get_members_query_txn(self, txn, where_clause, where_values): sql = ( "SELECT e.* FROM events as e " "INNER JOIN room_memberships as m " @@ -157,12 +191,11 @@ class RoomMemberStore(SQLBaseStore): "WHERE %s " ) % (where_clause,) - rows = yield self._execute_and_decode(sql, *where_values) + txn.execute(sql, where_values) + rows = self.cursor_to_dict(txn) - # logger.debug("_get_members_query Got rows %s", rows) - - results = yield self._parse_events(rows) - defer.returnValue(results) + results = self._parse_events_txn(txn, rows) + return results @defer.inlineCallbacks def user_rooms_intersect(self, user_list): diff --git a/synctl b/synctl index 99dc545ba..0f83e9cb1 100755 --- a/synctl +++ b/synctl @@ -4,7 +4,6 @@ SYNAPSE="synapse/app/homeserver.py" CONFIGFILE="homeserver.yaml" PIDFILE="homeserver.pid" -LOGFILE="homeserver.log" GREEN=$'\e[1;32m' NORMAL=$'\e[m' @@ -14,15 +13,13 @@ set -e case "$1" in start) if [ ! -f "$CONFIGFILE" ]; then - echo "No config file found - generating a default one..." - $SYNAPSE -c "$CONFIGFILE" --generate-config - echo "Wrote $CONFIGFILE" - echo "You must now edit this file before continuing" + echo "No config file found" + echo "To generate a config file, run 'python --generate-config'" exit 1 fi echo -n "Starting ..." - $SYNAPSE --daemonize -c "$CONFIGFILE" --pid-file "$PIDFILE" --log-file "$LOGFILE" + $SYNAPSE --daemonize -c "$CONFIGFILE" --pid-file "$PIDFILE" echo "${GREEN}started${NORMAL}" ;; stop) diff --git a/tests/test_state.py b/tests/test_state.py index b01496c40..16af95b7b 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -15,21 +15,31 @@ from twisted.internet import defer from twisted.trial import unittest +from twisted.python.log import PythonLoggingObserver from synapse.state import StateHandler from synapse.storage.pdu import PduEntry from synapse.federation.pdu_codec import encode_event_id +from synapse.federation.units import Pdu from collections import namedtuple from mock import Mock +import logging +import mock + ReturnType = namedtuple( "StateReturnType", ["new_branch", "current_branch"] ) +def _gen_get_power_level(power_level_list): + def get_power_level(room_id, user_id): + return defer.succeed(power_level_list.get(user_id, None)) + return get_power_level + class StateTestCase(unittest.TestCase): def setUp(self): self.persistence = Mock(spec=[ @@ -38,6 +48,7 @@ class StateTestCase(unittest.TestCase): "get_latest_pdus_in_context", "get_current_state_pdu", "get_pdu", + "get_power_level", ]) self.replication = Mock(spec=["get_pdu"]) @@ -51,10 +62,12 @@ class StateTestCase(unittest.TestCase): @defer.inlineCallbacks def test_new_state_key(self): # We've never seen anything for this state before - new_pdu = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + new_pdu = new_fake_pdu("A", "test", "mem", "x", None, "u") + + self.persistence.get_power_level.side_effect = _gen_get_power_level({}) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu], []) + (ReturnType([new_pdu], []), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -74,11 +87,44 @@ class StateTestCase(unittest.TestCase): # We do a direct overwriting of the old state, i.e., the new state # points to the old state. - old_pdu = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) - new_pdu = new_fake_pdu_entry("B", "test", "mem", "x", "A", 5) + old_pdu = new_fake_pdu("A", "test", "mem", "x", None, "u1") + new_pdu = new_fake_pdu("B", "test", "mem", "x", "A", "u2") + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 5, + }) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu], [old_pdu]) + (ReturnType([new_pdu, old_pdu], [old_pdu]), None) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + + @defer.inlineCallbacks + def test_overwrite(self): + old_pdu_1 = new_fake_pdu("A", "test", "mem", "x", None, "u1") + old_pdu_2 = new_fake_pdu("B", "test", "mem", "x", "A", "u2") + new_pdu = new_fake_pdu("C", "test", "mem", "x", "B", "u3") + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 5, + "u3": 0, + }) + + self.persistence.get_unresolved_state_tree.return_value = ( + (ReturnType([new_pdu, old_pdu_2, old_pdu_1], [old_pdu_1]), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -98,12 +144,18 @@ class StateTestCase(unittest.TestCase): # We try to update the state based on an outdated state, and have a # too low power level. - old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) - old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) - new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 5) + old_pdu_1 = new_fake_pdu("A", "test", "mem", "x", None, "u1") + old_pdu_2 = new_fake_pdu("B", "test", "mem", "x", None, "u2") + new_pdu = new_fake_pdu("C", "test", "mem", "x", "A", "u3") + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 10, + "u3": 5, + }) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + (ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -123,12 +175,18 @@ class StateTestCase(unittest.TestCase): # We try to update the state based on an outdated state, but have # sufficient power level to force the update. - old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) - old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) - new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 15) + old_pdu_1 = new_fake_pdu("A", "test", "mem", "x", None, "u1") + old_pdu_2 = new_fake_pdu("B", "test", "mem", "x", None, "u2") + new_pdu = new_fake_pdu("C", "test", "mem", "x", "A", "u3") + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 10, + "u3": 15, + }) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + (ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -148,12 +206,18 @@ class StateTestCase(unittest.TestCase): # We try to update the state based on an outdated state, the power # levels are the same and so are the branch lengths - old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) - old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) - new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 10) + old_pdu_1 = new_fake_pdu("A", "test", "mem", "x", None, "u1") + old_pdu_2 = new_fake_pdu("B", "test", "mem", "x", None, "u2") + new_pdu = new_fake_pdu("C", "test", "mem", "x", "A", "u3") + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 10, + "u3": 10, + }) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + (ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -173,13 +237,26 @@ class StateTestCase(unittest.TestCase): # We try to update the state based on an outdated state, the power # levels are the same but the branch length of the new one is longer. - old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) - old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) - old_pdu_3 = new_fake_pdu_entry("C", "test", "mem", "x", "A", 10) - new_pdu = new_fake_pdu_entry("D", "test", "mem", "x", "C", 10) + old_pdu_1 = new_fake_pdu("A", "test", "mem", "x", None, "u1") + old_pdu_2 = new_fake_pdu("B", "test", "mem", "x", None, "u2") + old_pdu_3 = new_fake_pdu("C", "test", "mem", "x", "A", "u3") + new_pdu = new_fake_pdu("D", "test", "mem", "x", "C", "u4") + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 10, + "u3": 10, + "u4": 10, + }) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu_3, old_pdu_1], [old_pdu_2, old_pdu_1]) + ( + ReturnType( + [new_pdu, old_pdu_3, old_pdu_1], + [old_pdu_2, old_pdu_1] + ), + None + ) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -200,22 +277,38 @@ class StateTestCase(unittest.TestCase): # triggering a get_pdu request # The pdu we haven't seen - old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + old_pdu_1 = new_fake_pdu( + "A", "test", "mem", "x", None, "u1", depth=0 + ) - old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) - new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 20) + old_pdu_2 = new_fake_pdu( + "B", "test", "mem", "x", "A", "u2", depth=1 + ) + new_pdu = new_fake_pdu( + "C", "test", "mem", "x", "A", "u3", depth=2 + ) + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 10, + "u3": 20, + }) # The return_value of `get_unresolved_state_tree`, which changes after # the call to get_pdu - tree_to_return = [ReturnType([new_pdu], [old_pdu_2])] + tree_to_return = [(ReturnType([new_pdu], [old_pdu_2]), 0)] def return_tree(p): return tree_to_return[0] - def set_return_tree(*args, **kwargs): - tree_to_return[0] = ReturnType( - [new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1] + def set_return_tree(destination, pdu_origin, pdu_id, outlier=False): + tree_to_return[0] = ( + ReturnType( + [new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1] + ), + None ) + return defer.succeed(None) self.persistence.get_unresolved_state_tree.side_effect = return_tree @@ -227,6 +320,13 @@ class StateTestCase(unittest.TestCase): self.assertTrue(is_new) + self.replication.get_pdu.assert_called_with( + destination=new_pdu.origin, + pdu_origin=old_pdu_1.origin, + pdu_id=old_pdu_1.pdu_id, + outlier=True + ) + self.persistence.get_unresolved_state_tree.assert_called_with( new_pdu ) @@ -237,12 +337,233 @@ class StateTestCase(unittest.TestCase): self.assertEqual(1, self.persistence.update_current_state.call_count) + @defer.inlineCallbacks + def test_missing_pdu_depth_1(self): + # We try to update state against a PDU we haven't yet seen, + # triggering a get_pdu request + + # The pdu we haven't seen + old_pdu_1 = new_fake_pdu( + "A", "test", "mem", "x", None, "u1", depth=0 + ) + + old_pdu_2 = new_fake_pdu( + "B", "test", "mem", "x", "A", "u2", depth=2 + ) + old_pdu_3 = new_fake_pdu( + "C", "test", "mem", "x", "B", "u3", depth=3 + ) + new_pdu = new_fake_pdu( + "D", "test", "mem", "x", "A", "u4", depth=4 + ) + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 10, + "u3": 10, + "u4": 20, + }) + + # The return_value of `get_unresolved_state_tree`, which changes after + # the call to get_pdu + tree_to_return = [ + ( + ReturnType([new_pdu], [old_pdu_3]), + 0 + ), + ( + ReturnType( + [new_pdu, old_pdu_1], [old_pdu_3] + ), + 1 + ), + ( + ReturnType( + [new_pdu, old_pdu_1], [old_pdu_3, old_pdu_2, old_pdu_1] + ), + None + ), + ] + + to_return = [0] + + def return_tree(p): + return tree_to_return[to_return[0]] + + def set_return_tree(destination, pdu_origin, pdu_id, outlier=False): + to_return[0] += 1 + return defer.succeed(None) + + self.persistence.get_unresolved_state_tree.side_effect = return_tree + + self.replication.get_pdu.side_effect = set_return_tree + + self.persistence.get_pdu.return_value = None + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.assertEqual(2, self.replication.get_pdu.call_count) + + self.replication.get_pdu.assert_has_calls( + [ + mock.call( + destination=new_pdu.origin, + pdu_origin=old_pdu_1.origin, + pdu_id=old_pdu_1.pdu_id, + outlier=True + ), + mock.call( + destination=old_pdu_3.origin, + pdu_origin=old_pdu_2.origin, + pdu_id=old_pdu_2.pdu_id, + outlier=True + ), + ] + ) + + self.persistence.get_unresolved_state_tree.assert_called_with( + new_pdu + ) + + self.assertEquals( + 3, self.persistence.get_unresolved_state_tree.call_count + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + @defer.inlineCallbacks + def test_missing_pdu_depth_2(self): + # We try to update state against a PDU we haven't yet seen, + # triggering a get_pdu request + + # The pdu we haven't seen + old_pdu_1 = new_fake_pdu( + "A", "test", "mem", "x", None, "u1", depth=0 + ) + + old_pdu_2 = new_fake_pdu( + "B", "test", "mem", "x", "A", "u2", depth=2 + ) + old_pdu_3 = new_fake_pdu( + "C", "test", "mem", "x", "B", "u3", depth=3 + ) + new_pdu = new_fake_pdu( + "D", "test", "mem", "x", "A", "u4", depth=1 + ) + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 10, + "u2": 10, + "u3": 10, + "u4": 20, + }) + + # The return_value of `get_unresolved_state_tree`, which changes after + # the call to get_pdu + tree_to_return = [ + ( + ReturnType([new_pdu], [old_pdu_3]), + 1, + ), + ( + ReturnType( + [new_pdu], [old_pdu_3, old_pdu_2] + ), + 0, + ), + ( + ReturnType( + [new_pdu, old_pdu_1], [old_pdu_3, old_pdu_2, old_pdu_1] + ), + None + ), + ] + + to_return = [0] + + def return_tree(p): + return tree_to_return[to_return[0]] + + def set_return_tree(destination, pdu_origin, pdu_id, outlier=False): + to_return[0] += 1 + return defer.succeed(None) + + self.persistence.get_unresolved_state_tree.side_effect = return_tree + + self.replication.get_pdu.side_effect = set_return_tree + + self.persistence.get_pdu.return_value = None + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.assertEqual(2, self.replication.get_pdu.call_count) + + self.replication.get_pdu.assert_has_calls( + [ + mock.call( + destination=old_pdu_3.origin, + pdu_origin=old_pdu_2.origin, + pdu_id=old_pdu_2.pdu_id, + outlier=True + ), + mock.call( + destination=new_pdu.origin, + pdu_origin=old_pdu_1.origin, + pdu_id=old_pdu_1.pdu_id, + outlier=True + ), + ] + ) + + self.persistence.get_unresolved_state_tree.assert_called_with( + new_pdu + ) + + self.assertEquals( + 3, self.persistence.get_unresolved_state_tree.call_count + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + @defer.inlineCallbacks + def test_no_common_ancestor(self): + # We do a direct overwriting of the old state, i.e., the new state + # points to the old state. + + old_pdu = new_fake_pdu("A", "test", "mem", "x", None, "u1") + new_pdu = new_fake_pdu("B", "test", "mem", "x", None, "u2") + + self.persistence.get_power_level.side_effect = _gen_get_power_level({ + "u1": 5, + "u2": 10, + }) + + self.persistence.get_unresolved_state_tree.return_value = ( + (ReturnType([new_pdu], [old_pdu]), None) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + @defer.inlineCallbacks def test_new_event(self): event = Mock() event.event_id = "12123123@test" - state_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 20) + state_pdu = new_fake_pdu("C", "test", "mem", "x", "A", 20) snapshot = Mock() snapshot.prev_state_pdu = state_pdu @@ -269,24 +590,25 @@ class StateTestCase(unittest.TestCase): ) -def new_fake_pdu_entry(pdu_id, context, pdu_type, state_key, prev_state_id, - power_level): - new_pdu = PduEntry( +def new_fake_pdu(pdu_id, context, pdu_type, state_key, prev_state_id, + user_id, depth=0): + new_pdu = Pdu( pdu_id=pdu_id, pdu_type=pdu_type, state_key=state_key, - power_level=power_level, + user_id=user_id, prev_state_id=prev_state_id, origin="example.com", context="context", ts=1405353060021, - depth=0, + depth=depth, content_json="{}", unrecognized_keys="{}", outlier=True, is_state=True, prev_state_origin="example.com", have_processed=True, + content={}, ) return new_pdu diff --git a/webclient/app-controller.js b/webclient/app-controller.js index 064bde3ab..6c3759878 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -21,8 +21,8 @@ limitations under the License. 'use strict'; angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'eventStreamService']) -.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', '$timeout', '$animate', 'matrixService', 'mPresence', 'eventStreamService', 'matrixPhoneService', - function($scope, $location, $rootScope, $timeout, $animate, matrixService, mPresence, eventStreamService, matrixPhoneService) { +.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', '$timeout', '$animate', 'matrixService', 'mPresence', 'eventStreamService', 'eventHandlerService', 'matrixPhoneService', + function($scope, $location, $rootScope, $timeout, $animate, matrixService, mPresence, eventStreamService, eventHandlerService, matrixPhoneService) { // Check current URL to avoid to display the logout button on the login page $scope.location = $location.path(); @@ -73,7 +73,10 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even // Clean permanent data matrixService.setConfig({}); matrixService.saveConfig(); - + + // Reset cached data + eventHandlerService.reset(); + // And go to the login page $location.url("login"); }; @@ -96,50 +99,80 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even delete roomMembers[matrixService.config().user_id]; $rootScope.currentCall.user_id = Object.keys(roomMembers)[0]; + + // set it to the user ID until we fetch the display name + $rootScope.currentCall.userProfile = { displayname: $rootScope.currentCall.user_id }; + matrixService.getProfile($rootScope.currentCall.user_id).then( function(response) { - $rootScope.currentCall.userProfile = response.data; + if (response.data.displayname) $rootScope.currentCall.userProfile.displayname = response.data.displayname; + if (response.data.avatar_url) $rootScope.currentCall.userProfile.avatar_url = response.data.avatar_url; }, function(error) { $scope.feedback = "Can't load user profile"; } ); }); + $rootScope.$watch('currentCall.state', function(newVal, oldVal) { + if (newVal == 'ringing') { + angular.element('#ringbackAudio')[0].pause(); + angular.element('#ringAudio')[0].load(); + angular.element('#ringAudio')[0].play(); + } else if (newVal == 'invite_sent') { + angular.element('#ringAudio')[0].pause(); + angular.element('#ringbackAudio')[0].load(); + angular.element('#ringbackAudio')[0].play(); + } else if (newVal == 'ended' && oldVal == 'connected') { + angular.element('#ringAudio')[0].pause(); + angular.element('#ringbackAudio')[0].pause(); + angular.element('#callendAudio')[0].play(); + } else if (newVal == 'ended' && oldVal == 'invite_sent' && $rootScope.currentCall.hangupParty == 'remote') { + angular.element('#ringAudio')[0].pause(); + angular.element('#ringbackAudio')[0].pause(); + angular.element('#busyAudio')[0].play(); + } else if (oldVal == 'invite_sent') { + angular.element('#ringbackAudio')[0].pause(); + } else if (oldVal == 'ringing') { + angular.element('#ringAudio')[0].pause(); + } + }); $rootScope.$on(matrixPhoneService.INCOMING_CALL_EVENT, function(ngEvent, call) { - console.trace("incoming call"); + console.log("incoming call"); + if ($rootScope.currentCall && $rootScope.currentCall.state != 'ended') { + console.log("rejecting call because we're already in a call"); + call.hangup(); + return; + } call.onError = $scope.onCallError; call.onHangup = $scope.onCallHangup; $rootScope.currentCall = call; }); + $rootScope.$on(matrixPhoneService.REPLACED_CALL_EVENT, function(ngEvent, oldCall, newCall) { + console.log("call ID "+oldCall.call_id+" has been replaced by call ID "+newCall.call_id+"!"); + newCall.onError = $scope.onCallError; + newCall.onHangup = $scope.onCallHangup; + $rootScope.currentCall = newCall; + }); + $scope.answerCall = function() { $rootScope.currentCall.answer(); }; $scope.hangupCall = function() { $rootScope.currentCall.hangup(); - - $timeout(function() { - var icon = angular.element('#callEndedIcon'); - $animate.addClass(icon, 'callIconRotate'); - $timeout(function(){ - $rootScope.currentCall = undefined; - }, 2000); - }, 100); }; $rootScope.onCallError = function(errStr) { $scope.feedback = errStr; } - $rootScope.onCallHangup = function() { - $timeout(function() { - var icon = angular.element('#callEndedIcon'); - $animate.addClass(icon, 'callIconRotate'); + $rootScope.onCallHangup = function(call) { + if (call == $rootScope.currentCall) { $timeout(function(){ - $rootScope.currentCall = undefined; - }, 2000); - }, 100); + if (call == $rootScope.currentCall) $rootScope.currentCall = undefined; + }, 4070); + } } }]); diff --git a/webclient/app.css b/webclient/app.css index 7698cb4fd..a277bd2a5 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -55,20 +55,20 @@ a:active { color: #000; } margin-left: 4px; margin-right: 4px; margin-top: 8px; - -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; - -moz-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; - -o-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; - transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; } -.callIconRotate { - -webkit-transform: rotateZ(45deg); - -moz-transform: rotateZ(45deg); - -ms-transform: rotateZ(45deg); - -o-transform: rotateZ(45deg); +#callEndedIcon { + transition:all linear 0.5s; +} + +#callEndedIcon { transform: rotateZ(45deg); } +#callEndedIcon.ng-hide { + transform: rotateZ(0deg); +} + #callPeerImage { width: 32px; height: 32px; @@ -220,12 +220,6 @@ a:active { color: #000; } height: 100%; } -#roomName { - float: right; - font-size: 16px; - margin-top: 15px; -} - #roomHeader { margin: auto; padding-left: 20px; @@ -263,12 +257,80 @@ a:active { color: #000; } #mainInput { width: 100%; + resize: none; } .blink { background-color: #faa; } +.roomHighlight { + font-weight: bold; +} + +.publicTable { + max-width: 480px; + width: 100%; + border-collapse: collapse; +} +.publicTable tr { + width: 100%; +} +.publicTable td { + vertical-align: text-top; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.publicRoomEntry { + max-width: 430px; +} + +.publicRoomJoinedUsers { + width: 5em; + text-align: right; + font-size: 12px; + color: #888; +} + +.publicRoomTopic { + color: #888; + font-size: 12px; + overflow: hidden; + padding-bottom: 5px; + border-bottom: 1px #ddd solid; +} + +#roomName { + font-size: 16px; + text-align: right; +} + +#roomTopic { + font-size: 13px; + text-align: right; +} + +.roomNameInput, .roomTopicInput { + width: 100%; +} + +.roomNameSection, .roomTopicSection { + float: right; + width: 100%; +} + +.roomNameSetNew, .roomTopicSetNew { + float: right; +} + +.roomHeaderInfo { + float: right; + margin-top: 15px; + width: 50%; +} + /*** Participant list ***/ #usersTableWrapper { @@ -465,6 +527,10 @@ a:active { color: #000; } text-align: left ! important; } +.bubble .message { + /* Break lines when encountering CR+LF */ + white-space: pre; +} .bubble .messagePending { opacity: 0.3 } diff --git a/webclient/app.js b/webclient/app.js index d25e2a623..9370f773b 100644 --- a/webclient/app.js +++ b/webclient/app.js @@ -16,6 +16,7 @@ limitations under the License. var matrixWebClient = angular.module('matrixWebClient', [ 'ngRoute', + 'ngAnimate', 'MatrixWebClientController', 'LoginController', 'RegisterController', diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index d2bb31053..705a5a07f 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -35,13 +35,20 @@ angular.module('eventHandlerService', []) var POWERLEVEL_EVENT = "POWERLEVEL_EVENT"; var CALL_EVENT = "CALL_EVENT"; var NAME_EVENT = "NAME_EVENT"; + var TOPIC_EVENT = "TOPIC_EVENT"; + var RESET_EVENT = "RESET_EVENT"; // eventHandlerService has been resetted + + var initialSyncDeferred; + + var reset = function() { + initialSyncDeferred = $q.defer(); + + $rootScope.events = { + rooms: {} // will contain roomId: { messages:[], members:{userid1: event} } + }; + } + reset(); - var InitialSyncDeferred = $q.defer(); - - $rootScope.events = { - rooms: {} // will contain roomId: { messages:[], members:{userid1: event} } - }; - // used for dedupping events - could be expanded in future... // FIXME: means that we leak memory over time (along with lots of the rest // of the app, given we never try to reap memory yet) @@ -55,6 +62,11 @@ angular.module('eventHandlerService', []) $rootScope.events.rooms[room_id] = {}; $rootScope.events.rooms[room_id].messages = []; $rootScope.events.rooms[room_id].members = {}; + + // Pagination information + $rootScope.events.rooms[room_id].pagination = { + earliest_token: "END" // how far back we've paginated + }; } }; @@ -64,9 +76,37 @@ angular.module('eventHandlerService', []) } }; - var handleRoomCreate = function(event, isLiveEvent) { - initRoom(event.room_id); + // Generic method to handle events data + var handleRoomDateEvent = function(event, isLiveEvent, addToRoomMessages) { + // Add topic changes as if they were a room message + if (addToRoomMessages) { + if (isLiveEvent) { + $rootScope.events.rooms[event.room_id].messages.push(event); + } + else { + $rootScope.events.rooms[event.room_id].messages.unshift(event); + } + } + // live events always update, but non-live events only update if the + // ts is later. + var latestData = true; + if (!isLiveEvent) { + var eventTs = event.ts; + var storedEvent = $rootScope.events.rooms[event.room_id][event.type]; + if (storedEvent) { + if (storedEvent.ts > eventTs) { + // ignore it, we have a newer one already. + latestData = false; + } + } + } + if (latestData) { + $rootScope.events.rooms[event.room_id][event.type] = event; + } + }; + + var handleRoomCreate = function(event, isLiveEvent) { // For now, we do not use the event data. Simply signal it to the app controllers $rootScope.$broadcast(ROOM_CREATE_EVENT, event, isLiveEvent); }; @@ -76,13 +116,18 @@ angular.module('eventHandlerService', []) }; var handleMessage = function(event, isLiveEvent) { - initRoom(event.room_id); - if (isLiveEvent) { if (event.user_id === matrixService.config().user_id && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") ) { - // assume we've already echoed it - // FIXME: track events by ID and ungrey the right message to show it's been delivered + // Assume we've already echoed it. So, there is a fake event in the messages list of the room + // Replace this fake event by the true one + var index = getRoomEventIndex(event.room_id, event.event_id); + if (index) { + $rootScope.events.rooms[event.room_id].messages[index] = event; + } + else { + $rootScope.events.rooms[event.room_id].messages.push(event); + } } else { $rootScope.events.rooms[event.room_id].messages.push(event); @@ -100,9 +145,7 @@ angular.module('eventHandlerService', []) $rootScope.$broadcast(MSG_EVENT, event, isLiveEvent); }; - var handleRoomMember = function(event, isLiveEvent) { - initRoom(event.room_id); - + var handleRoomMember = function(event, isLiveEvent, isStateEvent) { // if the server is stupidly re-relaying a no-op join, discard it. if (event.prev_content && event.content.membership === "join" && @@ -112,7 +155,10 @@ angular.module('eventHandlerService', []) } // add membership changes as if they were a room message if something interesting changed - if (event.content.prev !== event.content.membership) { + // Exception: Do not do this if the event is a room state event because such events already come + // as room messages events. Moreover, when they come as room messages events, they are relatively ordered + // with other other room messages + if (event.content.prev !== event.content.membership && !isStateEvent) { if (isLiveEvent) { $rootScope.events.rooms[event.room_id].messages.push(event); } @@ -121,8 +167,13 @@ angular.module('eventHandlerService', []) } } - $rootScope.events.rooms[event.room_id].members[event.state_key] = event; - $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent); + // Use data from state event or the latest data from the stream. + // Do not care of events that come when paginating back + if (isStateEvent || isLiveEvent) { + $rootScope.events.rooms[event.room_id].members[event.state_key] = event; + } + + $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent, isStateEvent); }; var handlePresence = function(event, isLiveEvent) { @@ -131,8 +182,6 @@ angular.module('eventHandlerService', []) }; var handlePowerLevels = function(event, isLiveEvent) { - initRoom(event.room_id); - // Keep the latest data. Do not care of events that come when paginating back if (!$rootScope.events.rooms[event.room_id][event.type] || isLiveEvent) { $rootScope.events.rooms[event.room_id][event.type] = event; @@ -140,19 +189,50 @@ angular.module('eventHandlerService', []) } }; - var handleRoomName = function(event, isLiveEvent) { - console.log("handleRoomName " + isLiveEvent); - - initRoom(event.room_id); - - $rootScope.events.rooms[event.room_id][event.type] = event; + var handleRoomName = function(event, isLiveEvent, isStateEvent) { + console.log("handleRoomName room_id: " + event.room_id + " - isLiveEvent: " + isLiveEvent + " - name: " + event.content.name); + handleRoomDateEvent(event, isLiveEvent, !isStateEvent); $rootScope.$broadcast(NAME_EVENT, event, isLiveEvent); }; + + + var handleRoomTopic = function(event, isLiveEvent, isStateEvent) { + console.log("handleRoomTopic room_id: " + event.room_id + " - isLiveEvent: " + isLiveEvent + " - topic: " + event.content.topic); + handleRoomDateEvent(event, isLiveEvent, !isStateEvent); + $rootScope.$broadcast(TOPIC_EVENT, event, isLiveEvent); + }; var handleCallEvent = function(event, isLiveEvent) { $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent); + if (event.type == 'm.call.invite') { + $rootScope.events.rooms[event.room_id].messages.push(event); + } }; + /** + * Get the index of the event in $rootScope.events.rooms[room_id].messages + * @param {type} room_id the room id + * @param {type} event_id the event id to look for + * @returns {Number | undefined} the index. undefined if not found. + */ + var getRoomEventIndex = function(room_id, event_id) { + var index; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + // Start looking from the tail since the first goal of this function + // is to find a messaged among the latest ones + for (var i = room.messages.length - 1; i > 0; i--) { + var message = room.messages[i]; + if (event_id === message.event_id) { + index = i; + break; + } + } + } + return index; + } + return { ROOM_CREATE_EVENT: ROOM_CREATE_EVENT, MSG_EVENT: MSG_EVENT, @@ -161,19 +241,37 @@ angular.module('eventHandlerService', []) POWERLEVEL_EVENT: POWERLEVEL_EVENT, CALL_EVENT: CALL_EVENT, NAME_EVENT: NAME_EVENT, + TOPIC_EVENT: TOPIC_EVENT, + RESET_EVENT: RESET_EVENT, + + reset: function() { + reset(); + $rootScope.$broadcast(RESET_EVENT); + }, - handleEvent: function(event, isLiveEvent) { - // FIXME: event duplication suppression is all broken as the code currently expect to handles - // events multiple times to get their side-effects... -/* - if (eventMap[event.event_id]) { - console.log("discarding duplicate event: " + JSON.stringify(event)); - return; + handleEvent: function(event, isLiveEvent, isStateEvent) { + + // FIXME: /initialSync on a particular room is not yet available + // So initRoom on a new room is not called. Make sure the room data is initialised here + initRoom(event.room_id); + + // Avoid duplicated events + // Needed for rooms where initialSync has not been done. + // In this case, we do not know where to start pagination. So, it starts from the END + // and we can have the same event (ex: joined, invitation) coming from the pagination + // AND from the event stream. + // FIXME: This workaround should be no more required when /initialSync on a particular room + // will be available (as opposite to the global /initialSync done at startup) + if (!isStateEvent) { // Do not consider state events + if (event.event_id && eventMap[event.event_id]) { + console.log("discarding duplicate event: " + JSON.stringify(event, undefined, 4)); + return; + } + else { + eventMap[event.event_id] = 1; + } } - else { - eventMap[event.event_id] = 1; - } -*/ + if (event.type.indexOf('m.call.') === 0) { handleCallEvent(event, isLiveEvent); } @@ -189,7 +287,7 @@ angular.module('eventHandlerService', []) handleMessage(event, isLiveEvent); break; case "m.room.member": - handleRoomMember(event, isLiveEvent); + handleRoomMember(event, isLiveEvent, isStateEvent); break; case "m.presence": handlePresence(event, isLiveEvent); @@ -202,7 +300,10 @@ angular.module('eventHandlerService', []) handlePowerLevels(event, isLiveEvent); break; case 'm.room.name': - handleRoomName(event, isLiveEvent); + handleRoomName(event, isLiveEvent, isStateEvent); + break; + case 'm.room.topic': + handleRoomTopic(event, isLiveEvent, isStateEvent); break; default: console.log("Unable to handle event type " + event.type); @@ -214,20 +315,30 @@ angular.module('eventHandlerService', []) // isLiveEvents determines whether notifications should be shown, whether // messages get appended to the start/end of lists, etc. - handleEvents: function(events, isLiveEvents) { + handleEvents: function(events, isLiveEvents, isStateEvents) { for (var i=0; i 5) { + console.log("Failed to send event of type "+ev.type+" on attempt "+ev.tries+". Giving up."); + return; + } + var delayMs = 500 * Math.pow(2, ev.tries); + console.log("Failed to send event of type "+ev.type+". Retrying in "+delayMs+"ms"); + ++ev.tries; + var self = this; + $timeout(function() { + matrixService.sendEvent(self.room_id, ev.type, undefined, ev.content).then(self.eventSent, function(error) { self.eventSendFailed(ev, error); } ); + }, delayMs); }; return MatrixCall; diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js index 260e0827d..015a88bca 100644 --- a/webclient/components/matrix/matrix-filter.js +++ b/webclient/components/matrix/matrix-filter.js @@ -31,15 +31,17 @@ angular.module('matrixFilter', []) } if (undefined === roomName) { - // Else, build the name from its users + var room = $rootScope.events.rooms[room_id]; if (room) { + // Get name from room state date var room_name_event = room["m.room.name"]; - if (room_name_event) { roomName = room_name_event.content.name; } else if (room.members) { + // Else, build the name from its users + // FIXME: Is it still required? // Limit the room renaming to 1:1 room if (2 === Object.keys(room.members).length) { for (var i in room.members) { diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js index ca86b473e..b0dcf1910 100644 --- a/webclient/components/matrix/matrix-phone-service.js +++ b/webclient/components/matrix/matrix-phone-service.js @@ -22,6 +22,7 @@ angular.module('matrixPhoneService', []) }; matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT"; + matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT"; matrixPhoneService.allCalls = {}; matrixPhoneService.callPlaced = function(call) { @@ -38,29 +39,59 @@ angular.module('matrixPhoneService', []) call.call_id = msg.call_id; call.initWithInvite(msg); matrixPhoneService.allCalls[call.call_id] = call; - $rootScope.$broadcast(matrixPhoneService.INCOMING_CALL_EVENT, call); + + // Were we trying to call that user (room)? + var existingCall; + var callIds = Object.keys(matrixPhoneService.allCalls); + for (var i = 0; i < callIds.length; ++i) { + var thisCallId = callIds[i]; + var thisCall = matrixPhoneService.allCalls[thisCallId]; + + if (call.room_id == thisCall.room_id && thisCall.direction == 'outbound' + && (thisCall.state == 'wait_local_media' || thisCall.state == 'create_offer' || thisCall.state == 'invite_sent')) { + existingCall = thisCall; + break; + } + } + + if (existingCall) { + // If we've only got to wait_local_media or create_offer and we've got an invite, + // pick the incoming call because we know we haven't sent our invite yet + // otherwise, pick whichever call has the lowest call ID (by string comparison) + if (existingCall.state == 'wait_local_media' || existingCall.state == 'create_offer' || existingCall.call_id > call.call_id) { + console.log("Glare detected: answering incoming call "+call.call_id+" and canceling outgoing call "+existingCall.call_id); + existingCall.replacedBy(call); + call.answer(); + $rootScope.$broadcast(matrixPhoneService.REPLACED_CALL_EVENT, existingCall, call); + } else { + console.log("Glare detected: rejecting incoming call "+call.call_id+" and keeping outgoing call "+existingCall.call_id); + call.hangup(); + } + } else { + $rootScope.$broadcast(matrixPhoneService.INCOMING_CALL_EVENT, call); + } } else if (event.type == 'm.call.answer') { var call = matrixPhoneService.allCalls[msg.call_id]; if (!call) { - console.trace("Got answer for unknown call ID "+msg.call_id); + console.log("Got answer for unknown call ID "+msg.call_id); return; } call.receivedAnswer(msg); } else if (event.type == 'm.call.candidate') { var call = matrixPhoneService.allCalls[msg.call_id]; if (!call) { - console.trace("Got candidate for unknown call ID "+msg.call_id); + console.log("Got candidate for unknown call ID "+msg.call_id); return; } call.gotRemoteIceCandidate(msg.candidate); } else if (event.type == 'm.call.hangup') { var call = matrixPhoneService.allCalls[msg.call_id]; if (!call) { - console.trace("Got hangup for unknown call ID "+msg.call_id); + console.log("Got hangup for unknown call ID "+msg.call_id); return; } call.onHangupReceived(); - matrixPhoneService.allCalls[msg.call_id] = undefined; + delete(matrixPhoneService.allCalls[msg.call_id]); } }); diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 3c28c52fb..68ef16800 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -130,8 +130,9 @@ angular.module('matrixService', []) return doRequest("POST", path, undefined, req); }, - // List all rooms joined or been invited to - rooms: function(limit, feedback) { + // Get the user's current state: his presence, the list of his rooms with + // the last {limit} events + initialSync: function(limit, feedback) { // The REST path spec var path = "/initialSync"; @@ -234,6 +235,32 @@ angular.module('matrixService', []) return doRequest("GET", path, undefined, {}); }, + + setName: function(room_id, name) { + var data = { + name: name + }; + return this.sendStateEvent(room_id, "m.room.name", data); + }, + + setTopic: function(room_id, topic) { + var data = { + topic: topic + }; + return this.sendStateEvent(room_id, "m.room.topic", data); + }, + + + sendStateEvent: function(room_id, eventType, content, state_key) { + var path = "/rooms/$room_id/state/"+eventType; + if (state_key !== undefined) { + path += "/" + state_key; + } + room_id = encodeURIComponent(room_id); + path = path.replace("$room_id", room_id); + + return doRequest("PUT", path, undefined, content); + }, sendEvent: function(room_id, eventType, txn_id, content) { // The REST path spec diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js index 85e8990c2..c0c4ea11a 100644 --- a/webclient/home/home-controller.js +++ b/webclient/home/home-controller.js @@ -142,4 +142,9 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen refresh(); }; + + // Clean data when user logs out + $scope.$on(eventHandlerService.RESET_EVENT, function() { + $scope.public_rooms = []; + }); }]); diff --git a/webclient/home/home.html b/webclient/home/home.html index 7240e79f8..5a1e18e1d 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -24,11 +24,30 @@

Public rooms

-
-
- {{ room.room_display_name }} -
-
+ + + + + + + + + + +
+ + {{ room.room_display_name }} + + +
+ {{ room.num_joined_members }} {{ room.num_joined_members == 1 ? 'user' : 'users' }} +
+
+ {{ room.topic }} +

diff --git a/webclient/index.html b/webclient/index.html index 81c7c7d06..9eea08215 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -15,7 +15,9 @@ + + @@ -56,9 +58,11 @@
Calling... + Incoming Call Call Connecting... Call Connected - Call Rejected + Call Rejected + Call Canceled Call Ended Call Canceled Call Ended @@ -70,6 +74,22 @@ + + + +
{{ user_id }}   diff --git a/webclient/js/angular-animate.js b/webclient/js/angular-animate.js index bea4bc523..c15f793c1 100644 --- a/webclient/js/angular-animate.js +++ b/webclient/js/angular-animate.js @@ -1,5 +1,5 @@ /** - * @license AngularJS v1.2.20 + * @license AngularJS v1.3.0-rc.1 * (c) 2010-2014 Google, Inc. http://angularjs.org * License: MIT */ @@ -12,11 +12,8 @@ * @name ngAnimate * @description * - * # ngAnimate - * * The `ngAnimate` module provides support for JavaScript, CSS3 transition and CSS3 keyframe animation hooks within existing core and custom directives. * - * *
* * # Usage @@ -28,17 +25,18 @@ * * Below is a more detailed breakdown of the supported animation events provided by pre-existing ng directives: * - * | Directive | Supported Animations | - * |---------------------------------------------------------- |----------------------------------------------------| - * | {@link ng.directive:ngRepeat#usage_animations ngRepeat} | enter, leave and move | - * | {@link ngRoute.directive:ngView#usage_animations ngView} | enter and leave | - * | {@link ng.directive:ngInclude#usage_animations ngInclude} | enter and leave | - * | {@link ng.directive:ngSwitch#usage_animations ngSwitch} | enter and leave | - * | {@link ng.directive:ngIf#usage_animations ngIf} | enter and leave | - * | {@link ng.directive:ngClass#usage_animations ngClass} | add and remove | - * | {@link ng.directive:ngShow#usage_animations ngShow & ngHide} | add and remove (the ng-hide class value) | - * | {@link ng.directive:form#usage_animations form} | add and remove (dirty, pristine, valid, invalid & all other validations) | - * | {@link ng.directive:ngModel#usage_animations ngModel} | add and remove (dirty, pristine, valid, invalid & all other validations) | + * | Directive | Supported Animations | + * |-----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------| + * | {@link ng.directive:ngRepeat#usage_animations ngRepeat} | enter, leave and move | + * | {@link ngRoute.directive:ngView#usage_animations ngView} | enter and leave | + * | {@link ng.directive:ngInclude#usage_animations ngInclude} | enter and leave | + * | {@link ng.directive:ngSwitch#usage_animations ngSwitch} | enter and leave | + * | {@link ng.directive:ngIf#usage_animations ngIf} | enter and leave | + * | {@link ng.directive:ngClass#usage_animations ngClass} | add and remove (the CSS class(es) present) | + * | {@link ng.directive:ngShow#usage_animations ngShow} & {@link ng.directive:ngHide#usage_animations ngHide} | add and remove (the ng-hide class value) | + * | {@link ng.directive:form#usage_animations form} & {@link ng.directive:ngModel#usage_animations ngModel} | add and remove (dirty, pristine, valid, invalid & all other validations) | + * | {@link ngMessages.directive:ngMessage#usage_animations ngMessages} | add and remove (ng-active & ng-inactive) | + * | {@link ngMessages.directive:ngMessage#usage_animations ngMessage} | enter and leave | * * You can find out more information about animations upon visiting each directive page. * @@ -52,9 +50,9 @@ * } * * .slide.ng-enter { } /* starting animations for enter */ - * .slide.ng-enter-active { } /* terminal animations for enter */ + * .slide.ng-enter.ng-enter-active { } /* terminal animations for enter */ * .slide.ng-leave { } /* starting animations for leave */ - * .slide.ng-leave-active { } /* terminal animations for leave */ + * .slide.ng-leave.ng-leave-active { } /* terminal animations for leave */ * * * /g, DOCTYPE_REGEXP = /]*?)>/i, CDATA_REGEXP = //g, - URI_REGEXP = /^((ftp|https?):\/\/|mailto:|tel:|#)/i, + SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g, // Match everything outside of normal chars and " (quote character) NON_ALPHANUMERIC_REGEXP = /([^\#-~| |!])/g; @@ -197,7 +214,7 @@ var validAttrs = angular.extend({}, uriAttrs, makeMap( 'abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,'+ 'color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,'+ 'ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,'+ - 'scope,scrolling,shape,span,start,summary,target,title,type,'+ + 'scope,scrolling,shape,size,span,start,summary,target,title,type,'+ 'valign,value,vspace,width')); function makeMap(str) { @@ -220,10 +237,18 @@ function makeMap(str) { * @param {object} handler */ function htmlParser( html, handler ) { - var index, chars, match, stack = [], last = html; + if (typeof html !== 'string') { + if (html === null || typeof html === 'undefined') { + html = ''; + } else { + html = '' + html; + } + } + var index, chars, match, stack = [], last = html, text; stack.last = function() { return stack[ stack.length - 1 ]; }; while ( html ) { + text = ''; chars = true; // Make sure we're not in a script or style element @@ -244,7 +269,7 @@ function htmlParser( html, handler ) { match = html.match( DOCTYPE_REGEXP ); if ( match ) { - html = html.replace( match[0] , ''); + html = html.replace( match[0], ''); chars = false; } // end tag @@ -262,16 +287,23 @@ function htmlParser( html, handler ) { match = html.match( START_TAG_REGEXP ); if ( match ) { - html = html.substring( match[0].length ); - match[0].replace( START_TAG_REGEXP, parseStartTag ); + // We only have a valid start-tag if there is a '>'. + if ( match[4] ) { + html = html.substring( match[0].length ); + match[0].replace( START_TAG_REGEXP, parseStartTag ); + } chars = false; + } else { + // no ending tag found --- this piece should be encoded as an entity. + text += '<'; + html = html.substring(1); } } if ( chars ) { index = html.indexOf("<"); - var text = index < 0 ? html : html.substring( 0, index ); + text += index < 0 ? html : html.substring( 0, index ); html = index < 0 ? "" : html.substring( index ); if (handler.chars) handler.chars( decodeEntities(text) ); @@ -351,15 +383,32 @@ function htmlParser( html, handler ) { } } +var hiddenPre=document.createElement("pre"); +var spaceRe = /^(\s*)([\s\S]*?)(\s*)$/; /** * decodes all entities into regular string * @param value * @returns {string} A string with decoded entities. */ -var hiddenPre=document.createElement("pre"); function decodeEntities(value) { - hiddenPre.innerHTML=value.replace(/ * * @example - - + + -
+
Snippet: @@ -503,43 +560,44 @@ angular.module('ngSanitize', []).value('$sanitize', $sanitize);
- - + + it('should linkify the snippet with urls', function() { - expect(using('#linky-filter').binding('snippet | linky')). - toBe('Pretty text with some links: ' + - 'http://angularjs.org/, ' + - 'us@somewhere.org, ' + - 'another@somewhere.org, ' + - 'and one more: ftp://127.0.0.1/.'); + expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). + toBe('Pretty text with some links: http://angularjs.org/, us@somewhere.org, ' + + 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); + expect(element.all(by.css('#linky-filter a')).count()).toEqual(4); }); - it ('should not linkify snippet without the linky filter', function() { - expect(using('#escaped-html').binding('snippet')). - toBe("Pretty text with some links:\n" + - "http://angularjs.org/,\n" + - "mailto:us@somewhere.org,\n" + - "another@somewhere.org,\n" + - "and one more: ftp://127.0.0.1/."); + it('should not linkify snippet without the linky filter', function() { + expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()). + toBe('Pretty text with some links: http://angularjs.org/, mailto:us@somewhere.org, ' + + 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); + expect(element.all(by.css('#escaped-html a')).count()).toEqual(0); }); it('should update', function() { - input('snippet').enter('new http://link.'); - expect(using('#linky-filter').binding('snippet | linky')). - toBe('new http://link.'); - expect(using('#escaped-html').binding('snippet')).toBe('new http://link.'); + element(by.model('snippet')).clear(); + element(by.model('snippet')).sendKeys('new http://link.'); + expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). + toBe('new http://link.'); + expect(element.all(by.css('#linky-filter a')).count()).toEqual(1); + expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()) + .toBe('new http://link.'); }); it('should work with the target property', function() { - expect(using('#linky-target').binding("snippetWithTarget | linky:'_blank'")). - toBe('http://angularjs.org/'); + expect(element(by.id('linky-target')). + element(by.binding("snippetWithTarget | linky:'_blank'")).getText()). + toBe('http://angularjs.org/'); + expect(element(by.css('#linky-target a')).getAttribute('target')).toEqual('_blank'); }); - - + + */ -angular.module('ngSanitize').filter('linky', function() { +angular.module('ngSanitize').filter('linky', ['$sanitize', function($sanitize) { var LINKY_URL_REGEXP = - /((ftp|https?):\/\/|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>]/, + /((ftp|https?):\/\/|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>"]/, MAILTO_REGEXP = /^mailto:/; return function(text, target) { @@ -547,31 +605,43 @@ angular.module('ngSanitize').filter('linky', function() { var match; var raw = text; var html = []; - // TODO(vojta): use $sanitize instead - var writer = htmlSanitizeWriter(html); var url; var i; - var properties = {}; - if (angular.isDefined(target)) { - properties.target = target; - } while ((match = raw.match(LINKY_URL_REGEXP))) { // We can not end in these as they are sometimes found at the end of the sentence url = match[0]; // if we did not match ftp/http/mailto then assume mailto if (match[2] == match[3]) url = 'mailto:' + url; i = match.index; - writer.chars(raw.substr(0, i)); - properties.href = url; - writer.start('a', properties); - writer.chars(match[0].replace(MAILTO_REGEXP, '')); - writer.end('a'); + addText(raw.substr(0, i)); + addLink(url, match[0].replace(MAILTO_REGEXP, '')); raw = raw.substring(i + match[0].length); } - writer.chars(raw); - return html.join(''); + addText(raw); + return $sanitize(html.join('')); + + function addText(text) { + if (!text) { + return; + } + html.push(sanitizeText(text)); + } + + function addLink(url, text) { + html.push(''); + addText(text); + html.push(''); + } }; -}); +}]); })(window, window.angular); diff --git a/webclient/js/angular-sanitize.min.js b/webclient/js/angular-sanitize.min.js index 15bfb59ab..ce99bba18 100644 --- a/webclient/js/angular-sanitize.min.js +++ b/webclient/js/angular-sanitize.min.js @@ -1,14 +1,15 @@ /* - AngularJS v1.2.0 - (c) 2010-2012 Google, Inc. http://angularjs.org + AngularJS v1.3.0-rc.1 + (c) 2010-2014 Google, Inc. http://angularjs.org License: MIT */ -(function(m,g,n){'use strict';function h(a){var d={};a=a.split(",");var c;for(c=0;c=c;k--)d.end&&d.end(e[k]);e.length= -c}}var b,f,e=[],l=a;for(e.last=function(){return e[e.length-1]};a;){f=!0;if(e.last()&&v[e.last()])a=a.replace(RegExp("(.*)<\\s*\\/\\s*"+e.last()+"[^>]*>","i"),function(a,b){b=b.replace(F,"$1").replace(G,"$1");d.chars&&d.chars(p(b));return""}),k("",e.last());else{if(0===a.indexOf("\x3c!--"))b=a.indexOf("--",4),0<=b&&a.lastIndexOf("--\x3e",b)===b&&(d.comment&&d.comment(a.substring(4,b)),a=a.substring(b+3),f=!1);else if(w.test(a)){if(b=a.match(w))a=a.replace(b[0],""),f=!1}else if(H.test(a)){if(b=a.match(x))a= -a.substring(b[0].length),b[0].replace(x,k),f=!1}else I.test(a)&&(b=a.match(y))&&(a=a.substring(b[0].length),b[0].replace(y,c),f=!1);f&&(b=a.indexOf("<"),f=0>b?a:a.substring(0,b),a=0>b?"":a.substring(b),d.chars&&d.chars(p(f)))}if(a==l)throw J("badparse",a);l=a}k()}function p(a){q.innerHTML=a.replace(//g,">")}function A(a){var d= -!1,c=g.bind(a,a.push);return{start:function(a,b,f){a=g.lowercase(a);!d&&v[a]&&(d=a);d||!0!==B[a]||(c("<"),c(a),g.forEach(b,function(a,b){var d=g.lowercase(b);!0!==L[d]||!0===C[d]&&!a.match(M)||(c(" "),c(b),c('="'),c(z(a)),c('"'))}),c(f?"/>":">"))},end:function(a){a=g.lowercase(a);d||!0!==B[a]||(c(""));a==d&&(d=!1)},chars:function(a){d||c(z(a))}}}var J=g.$$minErr("$sanitize"),y=/^<\s*([\w:-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*>/,x=/^<\s*\/\s*([\w:-]+)[^>]*>/, -E=/([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g,I=/^]*?)>/i,G=/]/,d=/^mailto:/;return function(c,k){if(!c)return c;var b,f=c,e=[],l=A(e),h,m,n={};g.isDefined(k)&&(n.target=k);for(;b=f.match(a);)h=b[0],b[2]==b[3]&&(h="mailto:"+h),m=b.index,l.chars(f.substr(0,m)),n.href=h,l.start("a",n),l.chars(b[0].replace(d,"")),l.end("a"), -f=f.substring(m+b[0].length);l.chars(f);return e.join("")}})})(window,window.angular); +(function(q,g,r){'use strict';function F(a){var d=[];t(d,g.noop).chars(a);return d.join("")}function m(a){var d={};a=a.split(",");var c;for(c=0;c=c;e--)d.end&&d.end(f[e]);f.length=c}}"string"!==typeof a&&(a=null===a||"undefined"===typeof a?"":""+a);var b,l,f=[],n=a,h;for(f.last=function(){return f[f.length-1]};a;){h="";l=!0;if(f.last()&&y[f.last()])a=a.replace(new RegExp("(.*)<\\s*\\/\\s*"+f.last()+"[^>]*>","i"),function(a,b){b=b.replace(I,"$1").replace(J,"$1");d.chars&&d.chars(s(b));return""}),e("",f.last());else{if(0===a.indexOf("\x3c!--"))b=a.indexOf("--",4),0<=b&&a.lastIndexOf("--\x3e",b)===b&&(d.comment&&d.comment(a.substring(4, +b)),a=a.substring(b+3),l=!1);else if(z.test(a)){if(b=a.match(z))a=a.replace(b[0],""),l=!1}else if(K.test(a)){if(b=a.match(A))a=a.substring(b[0].length),b[0].replace(A,e),l=!1}else L.test(a)&&((b=a.match(B))?(b[4]&&(a=a.substring(b[0].length),b[0].replace(B,c)),l=!1):(h+="<",a=a.substring(1)));l&&(b=a.indexOf("<"),h+=0>b?a:a.substring(0,b),a=0>b?"":a.substring(b),d.chars&&d.chars(s(h)))}if(a==n)throw M("badparse",a);n=a}e()}function s(a){if(!a)return"";var d=N.exec(a);a=d[1];var c=d[3];if(d=d[2])p.innerHTML= +d.replace(//g,">")}function t(a,d){var c=!1,e=g.bind(a,a.push);return{start:function(a,l,f){a=g.lowercase(a);!c&&y[a]&&(c=a);c||!0!==D[a]||(e("<"),e(a),g.forEach(l,function(c,f){var k= +g.lowercase(f),l="img"===a&&"src"===k||"background"===k;!0!==Q[k]||!0===E[k]&&!d(c,l)||(e(" "),e(f),e('="'),e(C(c)),e('"'))}),e(f?"/>":">"))},end:function(a){a=g.lowercase(a);c||!0!==D[a]||(e(""));a==c&&(c=!1)},chars:function(a){c||e(C(a))}}}var M=g.$$minErr("$sanitize"),B=/^<((?:[a-zA-Z])[\w:-]*)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*(>?)/,A=/^<\/\s*([\w:-]+)[^>]*>/,H=/([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g,L=/^]*?)>/i,J=/"]/,c=/^mailto:/;return function(e,b){function l(a){a&&k.push(F(a))}function f(a,c){k.push("');l(c);k.push("")} +if(!e)return e;for(var n,h=e,k=[],m,p;n=h.match(d);)m=n[0],n[2]==n[3]&&(m="mailto:"+m),p=n.index,l(h.substr(0,p)),f(m,n[0].replace(c,"")),h=h.substring(p+n[0].length);l(h);return a(k.join(""))}}])})(window,window.angular); //# sourceMappingURL=angular-sanitize.min.js.map diff --git a/webclient/js/angular.js b/webclient/js/angular.js index 217c4036f..bdc97abb0 100644 --- a/webclient/js/angular.js +++ b/webclient/js/angular.js @@ -1,5 +1,5 @@ /** - * @license AngularJS v1.2.20 + * @license AngularJS v1.3.0-rc.1 * (c) 2010-2014 Google, Inc. http://angularjs.org * License: MIT */ @@ -30,10 +30,13 @@ * should all be static strings, not variables or general expressions. * * @param {string} module The namespace to use for the new minErr instance. + * @param {function} ErrorConstructor Custom error constructor to be instantiated when returning + * error from returned function, for cases when a particular type of error is useful. * @returns {function(code:string, template:string, ...templateArgs): Error} minErr instance */ -function minErr(module) { +function minErr(module, ErrorConstructor) { + ErrorConstructor = ErrorConstructor || Error; return function () { var code = arguments[0], prefix = '[' + (module ? module + ':' : '') + code + '] ', @@ -68,101 +71,99 @@ function minErr(module) { return match; }); - message = message + '\nhttp://errors.angularjs.org/1.2.20/' + + message = message + '\nhttp://errors.angularjs.org/1.3.0-rc.1/' + (module ? module + '/' : '') + code; for (i = 2; i < arguments.length; i++) { message = message + (i == 2 ? '?' : '&') + 'p' + (i-2) + '=' + encodeURIComponent(stringify(arguments[i])); } - - return new Error(message); + return new ErrorConstructor(message); }; } /* We need to tell jshint what variables are being exported */ -/* global - -angular, - -msie, - -jqLite, - -jQuery, - -slice, - -push, - -toString, - -ngMinErr, - -angularModule, - -nodeName_, - -uid, - -VALIDITY_STATE_PROPERTY, - - -lowercase, - -uppercase, - -manualLowercase, - -manualUppercase, - -nodeName_, - -isArrayLike, - -forEach, - -sortedKeys, - -forEachSorted, - -reverseParams, - -nextUid, - -setHashKey, - -extend, - -int, - -inherit, - -noop, - -identity, - -valueFn, - -isUndefined, - -isDefined, - -isObject, - -isString, - -isNumber, - -isDate, - -isArray, - -isFunction, - -isRegExp, - -isWindow, - -isScope, - -isFile, - -isBlob, - -isBoolean, - -trim, - -isElement, - -makeMap, - -map, - -size, - -includes, - -indexOf, - -arrayRemove, - -isLeafNode, - -copy, - -shallowCopy, - -equals, - -csp, - -concat, - -sliceArgs, - -bind, - -toJsonReplacer, - -toJson, - -fromJson, - -toBoolean, - -startingTag, - -tryDecodeURIComponent, - -parseKeyValue, - -toKeyValue, - -encodeUriSegment, - -encodeUriQuery, - -angularInit, - -bootstrap, - -snake_case, - -bindJQuery, - -assertArg, - -assertArgFn, - -assertNotHasOwnProperty, - -getter, - -getBlockElements, - -hasOwnProperty, +/* global angular: true, + msie: true, + jqLite: true, + jQuery: true, + slice: true, + push: true, + toString: true, + ngMinErr: true, + angularModule: true, + uid: true, + REGEX_STRING_REGEXP: true, + VALIDITY_STATE_PROPERTY: true, + lowercase: true, + uppercase: true, + manualLowercase: true, + manualUppercase: true, + nodeName_: true, + isArrayLike: true, + forEach: true, + sortedKeys: true, + forEachSorted: true, + reverseParams: true, + nextUid: true, + setHashKey: true, + extend: true, + int: true, + inherit: true, + noop: true, + identity: true, + valueFn: true, + isUndefined: true, + isDefined: true, + isObject: true, + isString: true, + isNumber: true, + isDate: true, + isArray: true, + isFunction: true, + isRegExp: true, + isWindow: true, + isScope: true, + isFile: true, + isBlob: true, + isBoolean: true, + isPromiseLike: true, + trim: true, + isElement: true, + makeMap: true, + map: true, + size: true, + includes: true, + arrayRemove: true, + isLeafNode: true, + copy: true, + shallowCopy: true, + equals: true, + csp: true, + concat: true, + sliceArgs: true, + bind: true, + toJsonReplacer: true, + toJson: true, + fromJson: true, + startingTag: true, + tryDecodeURIComponent: true, + parseKeyValue: true, + toKeyValue: true, + encodeUriSegment: true, + encodeUriQuery: true, + angularInit: true, + bootstrap: true, + getTestability: true, + snake_case: true, + bindJQuery: true, + assertArg: true, + assertArgFn: true, + assertNotHasOwnProperty: true, + getter: true, + getBlockNodes: true, + hasOwnProperty: true, + createMap: true, */ //////////////////////////////////// @@ -182,6 +183,8 @@ function minErr(module) { *
*/ +var REGEX_STRING_REGEXP = /^\/(.+)\/([a-z]*)$/; + // The name of a form control's ValidityState property. // This is used so that it's possible for internal tests to create mock ValidityStates. var VALIDITY_STATE_PROPERTY = 'validity'; @@ -247,8 +250,7 @@ var /** holds major version number for IE or NaN for real browsers */ /** @name angular */ angular = window.angular || (window.angular = {}), angularModule, - nodeName_, - uid = ['0', '0', '0']; + uid = 0; /** * IE 11 changed the format of the UserAgent string. @@ -289,9 +291,9 @@ function isArrayLike(obj) { * * @description * Invokes the `iterator` function once for each item in `obj` collection, which can be either an - * object or an array. The `iterator` function is invoked with `iterator(value, key)`, where `value` - * is the value of an object property or an array element and `key` is the object property key or - * array element index. Specifying a `context` for the function is optional. + * object or an array. The `iterator` function is invoked with `iterator(value, key, obj)`, where `value` + * is the value of an object property or an array element, `key` is the object property key or + * array element index and obj is the `obj` itself. Specifying a `context` for the function is optional. * * It is worth noting that `.forEach` does not iterate over inherited properties because it filters * using the `hasOwnProperty` method. @@ -310,26 +312,31 @@ function isArrayLike(obj) { * @param {Object=} context Object to become context (`this`) for the iterator function. * @returns {Object|Array} Reference to `obj`. */ + function forEach(obj, iterator, context) { - var key; + var key, length; if (obj) { if (isFunction(obj)) { for (key in obj) { // Need to check if hasOwnProperty exists, // as on IE8 the result of querySelectorAll is an object without a hasOwnProperty function if (key != 'prototype' && key != 'length' && key != 'name' && (!obj.hasOwnProperty || obj.hasOwnProperty(key))) { - iterator.call(context, obj[key], key); + iterator.call(context, obj[key], key, obj); + } + } + } else if (isArray(obj) || isArrayLike(obj)) { + var isPrimitive = typeof obj !== 'object'; + for (key = 0, length = obj.length; key < length; key++) { + if (isPrimitive || key in obj) { + iterator.call(context, obj[key], key, obj); } } } else if (obj.forEach && obj.forEach !== forEach) { - obj.forEach(iterator, context); - } else if (isArrayLike(obj)) { - for (key = 0; key < obj.length; key++) - iterator.call(context, obj[key], key); + obj.forEach(iterator, context, obj); } else { for (key in obj) { if (obj.hasOwnProperty(key)) { - iterator.call(context, obj[key], key); + iterator.call(context, obj[key], key, obj); } } } @@ -366,33 +373,17 @@ function reverseParams(iteratorFn) { } /** - * A consistent way of creating unique IDs in angular. The ID is a sequence of alpha numeric - * characters such as '012ABC'. The reason why we are not using simply a number counter is that - * the number string gets longer over time, and it can also overflow, where as the nextId - * will grow much slower, it is a string, and it will never overflow. + * A consistent way of creating unique IDs in angular. * - * @returns {string} an unique alpha-numeric string + * Using simple numbers allows us to generate 28.6 million unique ids per second for 10 years before + * we hit number precision issues in JavaScript. + * + * Math.pow(2,53) / 60 / 60 / 24 / 365 / 10 = 28.6M + * + * @returns {number} an unique alpha-numeric string */ function nextUid() { - var index = uid.length; - var digit; - - while(index) { - index--; - digit = uid[index].charCodeAt(0); - if (digit == 57 /*'9'*/) { - uid[index] = 'A'; - return uid.join(''); - } - if (digit == 90 /*'Z'*/) { - uid[index] = '0'; - } else { - uid[index] = String.fromCharCode(digit + 1); - return uid.join(''); - } - } - uid.unshift('0'); - return uid.join(''); + return ++uid; } @@ -426,15 +417,19 @@ function setHashKey(obj, h) { */ function extend(dst) { var h = dst.$$hashKey; - forEach(arguments, function(obj) { - if (obj !== dst) { - forEach(obj, function(value, key) { - dst[key] = value; - }); - } - }); - setHashKey(dst,h); + for (var i = 1, ii = arguments.length; i < ii; i++) { + var obj = arguments[i]; + if (obj) { + var keys = Object.keys(obj); + for (var j = 0, jj = keys.length; j < jj; j++) { + var key = keys[j]; + dst[key] = obj[key]; + } + } + } + + setHashKey(dst, h); return dst; } @@ -532,7 +527,10 @@ function isDefined(value){return typeof value !== 'undefined';} * @param {*} value Reference to check. * @returns {boolean} True if `value` is an `Object` but not `null`. */ -function isObject(value){return value != null && typeof value === 'object';} +function isObject(value){ + // http://jsperf.com/isobject4 + return value !== null && typeof value === 'object'; +} /** @@ -594,14 +592,7 @@ function isDate(value) { * @param {*} value Reference to check. * @returns {boolean} True if `value` is an `Array`. */ -var isArray = (function() { - if (!isFunction(Array.isArray)) { - return function(value) { - return toString.call(value) === '[object Array]'; - }; - } - return Array.isArray; -})(); +var isArray = Array.isArray; /** * @ngdoc function @@ -638,7 +629,7 @@ function isRegExp(value) { * @returns {boolean} True if `obj` is a window obj. */ function isWindow(obj) { - return obj && obj.document && obj.location && obj.alert && obj.setInterval; + return obj && obj.window === obj; } @@ -662,19 +653,14 @@ function isBoolean(value) { } -var trim = (function() { - // native trim is way faster: http://jsperf.com/angular-trim-test - // but IE doesn't have it... :-( - // TODO: we should move this into IE/ES5 polyfill - if (!String.prototype.trim) { - return function(value) { - return isString(value) ? value.replace(/^\s\s*/, '').replace(/\s\s*$/, '') : value; - }; - } - return function(value) { - return isString(value) ? value.trim() : value; - }; -})(); +function isPromiseLike(obj) { + return obj && isFunction(obj.then); +} + + +var trim = function(value) { + return isString(value) ? value.trim() : value; +}; /** @@ -707,16 +693,8 @@ function makeMap(str) { } -if (msie < 9) { - nodeName_ = function(element) { - element = element.nodeName ? element : element[0]; - return (element.scopeName && element.scopeName != 'HTML') - ? uppercase(element.scopeName + ':' + element.nodeName) : element.nodeName; - }; -} else { - nodeName_ = function(element) { - return element.nodeName ? element.nodeName : element[0].nodeName; - }; +function nodeName_(element) { + return lowercase(element.nodeName || element[0].nodeName); } @@ -757,20 +735,11 @@ function size(obj, ownPropsOnly) { function includes(array, obj) { - return indexOf(array, obj) != -1; -} - -function indexOf(array, obj) { - if (array.indexOf) return array.indexOf(obj); - - for (var i = 0; i < array.length; i++) { - if (obj === array[i]) return i; - } - return -1; + return Array.prototype.indexOf.call(array, obj) != -1; } function arrayRemove(array, value) { - var index = indexOf(array, value); + var index = array.indexOf(value); if (index >=0) array.splice(index, 1); return value; @@ -778,10 +747,10 @@ function arrayRemove(array, value) { function isLeafNode (node) { if (node) { - switch (node.nodeName) { - case "OPTION": - case "PRE": - case "TITLE": + switch (nodeName_(node)) { + case "option": + case "pre": + case "title": return true; } } @@ -826,7 +795,7 @@ function isLeafNode (node) {
- *
- * - * - * - * - * - * - * - *
{{heading}}
{{fill}}
+ * ```html + * + * + * + *
+ * {{greeting}} *
- * - * - * var app = angular.module('multi-bootstrap', []) * - * .controller('BrokenTable', function($scope) { - * $scope.headings = ['One', 'Two', 'Three']; - * $scope.fillings = [[1, 2, 3], ['A', 'B', 'C'], [7, 8, 9]]; - * }); - * - * - * it('should only insert one table cell for each item in $scope.fillings', function() { - * expect(element.all(by.css('td')).count()) - * .toBe(9); - * }); - * - * + * + * + * + * + * ``` * * @param {DOMElement} element DOM element which is the root of angular application. * @param {Array=} modules an array of modules to load into the application. * Each item in the array should be the name of a predefined module or a (DI annotated) * function that will be invoked by the injector as a run block. * See: {@link angular.module modules} + * @param {Object=} config an object for defining configuration options for the application. The + * following keys are supported: + * + * - `strictDi`: disable automatic function annotation for the application. This is meant to + * assist in finding bugs which break minified code. + * * @returns {auto.$injector} Returns the newly created injector for this app. */ -function bootstrap(element, modules) { +function bootstrap(element, modules, config) { + if (!isObject(config)) config = {}; + var defaultConfig = { + strictDi: false + }; + config = extend(defaultConfig, config); var doBootstrap = function() { element = jqLite(element); if (element.injector()) { var tag = (element[0] === document) ? 'document' : startingTag(element); - throw ngMinErr('btstrpd', "App Already Bootstrapped with this Element '{0}'", tag); + //Encode angle brackets to prevent input from being sanitized to empty string #8683 + throw ngMinErr( + 'btstrpd', + "App Already Bootstrapped with this Element '{0}'", + tag.replace(//,'>')); } modules = modules || []; modules.unshift(['$provide', function($provide) { $provide.value('$rootElement', element); }]); + + if (config.debugInfoEnabled) { + // Pushing so that this overrides `debugInfoEnabled` setting defined in user's `modules`. + modules.push(['$compileProvider', function($compileProvider) { + $compileProvider.debugInfoEnabled(true); + }]); + } + modules.unshift('ng'); - var injector = createInjector(modules); - injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', '$animate', - function(scope, element, compile, injector, animate) { + var injector = createInjector(modules, config.strictDi); + injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', + function bootstrapApply(scope, element, compile, injector) { scope.$apply(function() { element.data('$injector', injector); compile(element)(scope); @@ -1425,8 +1499,14 @@ function bootstrap(element, modules) { return injector; }; + var NG_ENABLE_DEBUG_INFO = /^NG_ENABLE_DEBUG_INFO!/; var NG_DEFER_BOOTSTRAP = /^NG_DEFER_BOOTSTRAP!/; + if (window && NG_ENABLE_DEBUG_INFO.test(window.name)) { + config.debugInfoEnabled = true; + window.name = window.name.replace(NG_ENABLE_DEBUG_INFO, ''); + } + if (window && !NG_DEFER_BOOTSTRAP.test(window.name)) { return doBootstrap(); } @@ -1440,6 +1520,33 @@ function bootstrap(element, modules) { }; } +/** + * @ngdoc function + * @name angular.reloadWithDebugInfo + * @module ng + * @description + * Use this function to reload the current application with debug information turned on. + * This takes precedence over a call to `$compileProvider.debugInfoEnabled(false)`. + * + * See {@link ng.$compileProvider#debugInfoEnabled} for more. + */ +function reloadWithDebugInfo() { + window.name = 'NG_ENABLE_DEBUG_INFO!' + window.name; + window.location.reload(); +} + +/* + * @name angular.getTestability + * @module ng + * @description + * Get the testability service for the instance of Angular on the given + * element. + * @param {DOMElement} element DOM element which is the root of angular application. + */ +function getTestability(rootElement) { + return angular.element(rootElement).injector().get('$$testability'); +} + var SNAKE_CASE_REGEXP = /[A-Z]/g; function snake_case(name, separator) { separator = separator || '_'; @@ -1448,11 +1555,21 @@ function snake_case(name, separator) { }); } +var bindJQueryFired = false; +var skipDestroyOnNextJQueryCleanData; function bindJQuery() { + var originalCleanData; + + if (bindJQueryFired) { + return; + } + // bind to jQuery if present; jQuery = window.jQuery; // Use jQuery if it exists with proper functionality, otherwise default to us. - // Angular 1.2+ requires jQuery 1.7.1+ for on()/off() support. + // Angular 1.2+ requires jQuery 1.7+ for on()/off() support. + // Angular 1.3+ technically requires at least jQuery 2.1+ but it may work with older + // versions. It will not work for sure with jQuery <1.7, though. if (jQuery && jQuery.fn.on) { jqLite = jQuery; extend(jQuery.fn, { @@ -1462,15 +1579,33 @@ function bindJQuery() { injector: JQLitePrototype.injector, inheritedData: JQLitePrototype.inheritedData }); - // Method signature: - // jqLitePatchJQueryRemove(name, dispatchThis, filterElems, getterIfNoArguments) - jqLitePatchJQueryRemove('remove', true, true, false); - jqLitePatchJQueryRemove('empty', false, false, false); - jqLitePatchJQueryRemove('html', false, false, true); + + // All nodes removed from the DOM via various jQuery APIs like .remove() + // are passed through jQuery.cleanData. Monkey-patch this method to fire + // the $destroy event on all removed nodes. + originalCleanData = jQuery.cleanData; + jQuery.cleanData = function(elems) { + var events; + if (!skipDestroyOnNextJQueryCleanData) { + for (var i = 0, elem; (elem = elems[i]) != null; i++) { + events = jQuery._data(elem, "events"); + if (events && events.$destroy) { + jQuery(elem).triggerHandler('$destroy'); + } + } + } else { + skipDestroyOnNextJQueryCleanData = false; + } + originalCleanData(elems); + }; } else { jqLite = JQLite; } + angular.element = jqLite; + + // Prevent double-proxying. + bindJQueryFired = true; } /** @@ -1534,25 +1669,38 @@ function getter(obj, path, bindFnToScope) { /** * Return the DOM siblings between the first and last node in the given array. * @param {Array} array like object - * @returns {DOMElement} object containing the elements + * @returns {jqLite} jqLite collection containing the nodes */ -function getBlockElements(nodes) { - var startNode = nodes[0], - endNode = nodes[nodes.length - 1]; - if (startNode === endNode) { - return jqLite(startNode); - } - - var element = startNode; - var elements = [element]; +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 { - element = element.nextSibling; - if (!element) break; - elements.push(element); - } while (element !== endNode); + node = node.nextSibling; + if (!node) break; + blockNodes.push(node); + } while (node !== endNode); - return jqLite(elements); + return jqLite(blockNodes); +} + + +/** + * Creates a new object without a prototype. This object is useful for lookup without having to + * guard against prototypically inherited properties via hasOwnProperty. + * + * Related micro-benchmarks: + * - http://jsperf.com/object-create2 + * - http://jsperf.com/proto-map-lookup/2 + * - http://jsperf.com/for-in-vs-object-keys2 + * + * @returns {Object} + */ +function createMap() { + return Object.create(null); } /** @@ -1654,22 +1802,26 @@ function setupModuleLoader(window) { /** @type {!Array.>} */ var invokeQueue = []; + /** @type {!Array.} */ + var configBlocks = []; + /** @type {!Array.} */ var runBlocks = []; - var config = invokeLater('$injector', 'invoke'); + var config = invokeLater('$injector', 'invoke', 'push', configBlocks); /** @type {angular.Module} */ var moduleInstance = { // Private state _invokeQueue: invokeQueue, + _configBlocks: configBlocks, _runBlocks: runBlocks, /** * @ngdoc property * @name angular.Module#requires * @module ng - * @returns {Array.} List of module names which must be loaded before this module. + * * @description * Holds the list of modules which the injector will load before the current module is * loaded. @@ -1680,8 +1832,9 @@ function setupModuleLoader(window) { * @ngdoc property * @name angular.Module#name * @module ng - * @returns {string} Name of the module. + * * @description + * Name of the module. */ name: name, @@ -1854,9 +2007,10 @@ function setupModuleLoader(window) { * @param {String=} insertMethod * @returns {angular.Module} */ - function invokeLater(provider, method, insertMethod) { + function invokeLater(provider, method, insertMethod, queue) { + if (!queue) queue = invokeQueue; return function() { - invokeQueue[insertMethod || 'push']([provider, method, arguments]); + queue[insertMethod || 'push']([provider, method, arguments]); return moduleInstance; }; } @@ -1866,81 +2020,90 @@ function setupModuleLoader(window) { } -/* global - angularModule: true, - version: true, +/* global angularModule: true, + version: true, - $LocaleProvider, - $CompileProvider, + $LocaleProvider, + $CompileProvider, - htmlAnchorDirective, - inputDirective, - inputDirective, - formDirective, - scriptDirective, - selectDirective, - styleDirective, - optionDirective, - ngBindDirective, - ngBindHtmlDirective, - ngBindTemplateDirective, - ngClassDirective, - ngClassEvenDirective, - ngClassOddDirective, - ngCspDirective, - ngCloakDirective, - ngControllerDirective, - ngFormDirective, - ngHideDirective, - ngIfDirective, - ngIncludeDirective, - ngIncludeFillContentDirective, - ngInitDirective, - ngNonBindableDirective, - ngPluralizeDirective, - ngRepeatDirective, - ngShowDirective, - ngStyleDirective, - ngSwitchDirective, - ngSwitchWhenDirective, - ngSwitchDefaultDirective, - ngOptionsDirective, - ngTranscludeDirective, - ngModelDirective, - ngListDirective, - ngChangeDirective, - requiredDirective, - requiredDirective, - ngValueDirective, - ngAttributeAliasDirectives, - ngEventDirectives, + htmlAnchorDirective, + inputDirective, + inputDirective, + formDirective, + scriptDirective, + selectDirective, + styleDirective, + optionDirective, + ngBindDirective, + ngBindHtmlDirective, + ngBindTemplateDirective, + ngClassDirective, + ngClassEvenDirective, + ngClassOddDirective, + ngCspDirective, + ngCloakDirective, + ngControllerDirective, + ngFormDirective, + ngHideDirective, + ngIfDirective, + ngIncludeDirective, + ngIncludeFillContentDirective, + ngInitDirective, + ngNonBindableDirective, + ngPluralizeDirective, + ngRepeatDirective, + ngShowDirective, + ngStyleDirective, + ngSwitchDirective, + ngSwitchWhenDirective, + ngSwitchDefaultDirective, + ngOptionsDirective, + ngTranscludeDirective, + ngModelDirective, + ngListDirective, + ngChangeDirective, + patternDirective, + patternDirective, + requiredDirective, + requiredDirective, + minlengthDirective, + minlengthDirective, + maxlengthDirective, + maxlengthDirective, + ngValueDirective, + ngModelOptionsDirective, + ngAttributeAliasDirectives, + ngEventDirectives, - $AnchorScrollProvider, - $AnimateProvider, - $BrowserProvider, - $CacheFactoryProvider, - $ControllerProvider, - $DocumentProvider, - $ExceptionHandlerProvider, - $FilterProvider, - $InterpolateProvider, - $IntervalProvider, - $HttpProvider, - $HttpBackendProvider, - $LocationProvider, - $LogProvider, - $ParseProvider, - $RootScopeProvider, - $QProvider, - $$SanitizeUriProvider, - $SceProvider, - $SceDelegateProvider, - $SnifferProvider, - $TemplateCacheProvider, - $TimeoutProvider, - $$RAFProvider, - $$AsyncCallbackProvider, - $WindowProvider + $AnchorScrollProvider, + $AnimateProvider, + $BrowserProvider, + $CacheFactoryProvider, + $ControllerProvider, + $DocumentProvider, + $ExceptionHandlerProvider, + $FilterProvider, + $InterpolateProvider, + $IntervalProvider, + $HttpProvider, + $HttpBackendProvider, + $LocationProvider, + $LogProvider, + $ParseProvider, + $RootScopeProvider, + $QProvider, + $$QProvider, + $$SanitizeUriProvider, + $SceProvider, + $SceDelegateProvider, + $SnifferProvider, + $TemplateCacheProvider, + $TemplateRequestProvider, + $$TestabilityProvider, + $TimeoutProvider, + $$RAFProvider, + $$AsyncCallbackProvider, + $WindowProvider */ @@ -1959,11 +2122,11 @@ function setupModuleLoader(window) { * - `codeName` – `{string}` – Code name of the release, such as "jiggling-armfat". */ var version = { - full: '1.2.20', // all of these placeholder strings will be replaced by grunt's + full: '1.3.0-rc.1', // all of these placeholder strings will be replaced by grunt's major: 1, // package task - minor: 2, - dot: 20, - codeName: 'accidental-beautification' + minor: 3, + dot: 0, + codeName: 'backyard-atomicity' }; @@ -1976,11 +2139,11 @@ function publishExternalAPI(angular){ 'element': jqLite, 'forEach': forEach, 'injector': createInjector, - 'noop':noop, - 'bind':bind, + 'noop': noop, + 'bind': bind, 'toJson': toJson, 'fromJson': fromJson, - 'identity':identity, + 'identity': identity, 'isUndefined': isUndefined, 'isDefined': isDefined, 'isString': isString, @@ -1994,8 +2157,11 @@ function publishExternalAPI(angular){ 'lowercase': lowercase, 'uppercase': uppercase, 'callbacks': {counter: 0}, + 'getTestability': getTestability, '$$minErr': minErr, - '$$csp': csp + '$$csp': csp, + 'reloadWithDebugInfo': reloadWithDebugInfo, + '$$hasClass': jqLiteHasClass }); angularModule = setupModuleLoader(window); @@ -2047,9 +2213,16 @@ function publishExternalAPI(angular){ ngModel: ngModelDirective, ngList: ngListDirective, ngChange: ngChangeDirective, + pattern: patternDirective, + ngPattern: patternDirective, required: requiredDirective, ngRequired: requiredDirective, - ngValue: ngValueDirective + minlength: minlengthDirective, + ngMinlength: minlengthDirective, + maxlength: maxlengthDirective, + ngMaxlength: maxlengthDirective, + ngValue: ngValueDirective, + ngModelOptions: ngModelOptionsDirective }). directive({ ngInclude: ngIncludeFillContentDirective @@ -2074,10 +2247,13 @@ function publishExternalAPI(angular){ $parse: $ParseProvider, $rootScope: $RootScopeProvider, $q: $QProvider, + $$q: $$QProvider, $sce: $SceProvider, $sceDelegate: $SceDelegateProvider, $sniffer: $SnifferProvider, $templateCache: $TemplateCacheProvider, + $templateRequest: $TemplateRequestProvider, + $$testability: $$TestabilityProvider, $timeout: $TimeoutProvider, $window: $WindowProvider, $$rAF: $$RAFProvider, @@ -2087,12 +2263,11 @@ function publishExternalAPI(angular){ ]); } -/* global - - -JQLitePrototype, - -addEventListenerFn, - -removeEventListenerFn, - -BOOLEAN_ATTR +/* global JQLitePrototype: true, + addEventListenerFn: true, + removeEventListenerFn: true, + BOOLEAN_ATTR: true, + ALIASED_ATTR: true, */ ////////////////////////////////// @@ -2134,6 +2309,7 @@ function publishExternalAPI(angular){ * - [`contents()`](http://api.jquery.com/contents/) * - [`css()`](http://api.jquery.com/css/) * - [`data()`](http://api.jquery.com/data/) + * - [`detach()`](http://api.jquery.com/detach/) * - [`empty()`](http://api.jquery.com/empty/) * - [`eq()`](http://api.jquery.com/eq/) * - [`find()`](http://api.jquery.com/find/) - Limited to lookups by tag name @@ -2189,17 +2365,17 @@ JQLite.expando = 'ng339'; var jqCache = JQLite.cache = {}, jqId = 1, - addEventListenerFn = (window.document.addEventListener - ? function(element, type, fn) {element.addEventListener(type, fn, false);} - : function(element, type, fn) {element.attachEvent('on' + type, fn);}), - removeEventListenerFn = (window.document.removeEventListener - ? function(element, type, fn) {element.removeEventListener(type, fn, false); } - : function(element, type, fn) {element.detachEvent('on' + type, fn); }); + addEventListenerFn = function(element, type, fn) { + element.addEventListener(type, fn, false); + }, + removeEventListenerFn = function(element, type, fn) { + element.removeEventListener(type, fn, false); + }; /* * !!! This is an undocumented "private" function !!! */ -var jqData = JQLite._data = function(node) { +JQLite._data = function(node) { //jQuery always returns an object on cache miss return this.cache[node[this.expando]] || {}; }; @@ -2209,6 +2385,7 @@ function jqNextId() { return ++jqId; } var SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g; var MOZ_HACK_REGEXP = /^moz([A-Z])/; +var MOUSE_EVENT_MAP= { mouseleave : "mouseout", mouseenter : "mouseover"}; var jqLiteMinErr = minErr('jqLite'); /** @@ -2224,49 +2401,6 @@ function camelCase(name) { replace(MOZ_HACK_REGEXP, 'Moz$1'); } -///////////////////////////////////////////// -// jQuery mutation patch -// -// In conjunction with bindJQuery intercepts all jQuery's DOM destruction apis and fires a -// $destroy event on all DOM nodes being removed. -// -///////////////////////////////////////////// - -function jqLitePatchJQueryRemove(name, dispatchThis, filterElems, getterIfNoArguments) { - var originalJqFn = jQuery.fn[name]; - originalJqFn = originalJqFn.$original || originalJqFn; - removePatch.$original = originalJqFn; - jQuery.fn[name] = removePatch; - - function removePatch(param) { - // jshint -W040 - var list = filterElems && param ? [this.filter(param)] : [this], - fireEvent = dispatchThis, - set, setIndex, setLength, - element, childIndex, childLength, children; - - if (!getterIfNoArguments || param != null) { - while(list.length) { - set = list.shift(); - for(setIndex = 0, setLength = set.length; setIndex < setLength; setIndex++) { - element = jqLite(set[setIndex]); - if (fireEvent) { - element.triggerHandler('$destroy'); - } else { - fireEvent = !fireEvent; - } - for(childIndex = 0, childLength = (children = element.children()).length; - childIndex < childLength; - childIndex++) { - list.push(jQuery(children[childIndex])); - } - } - } - } - return originalJqFn.apply(this, arguments); - } -} - var SINGLE_TAG_REGEXP = /^<(\w+)\s*\/?>(?:<\/\1>|)$/; var HTML_REGEXP = /<|&#?\w+;/; var TAG_NAME_REGEXP = /<([\w:]+)/; @@ -2286,26 +2420,32 @@ wrapMap.optgroup = wrapMap.option; wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; + function jqLiteIsTextNode(html) { return !HTML_REGEXP.test(html); } +function jqLiteAcceptsData(node) { + // The window object can accept data but has no nodeType + // Otherwise we are only interested in elements (1) and documents (9) + var nodeType = node.nodeType; + return nodeType === 1 || !nodeType || nodeType === 9; +} + function jqLiteBuildFragment(html, context) { - var elem, tmp, tag, wrap, + var tmp, tag, wrap, fragment = context.createDocumentFragment(), - nodes = [], i, j, jj; + nodes = [], i; if (jqLiteIsTextNode(html)) { // Convert non-html into a text node nodes.push(context.createTextNode(html)); } else { - tmp = fragment.appendChild(context.createElement('div')); // Convert html into DOM nodes + tmp = tmp || fragment.appendChild(context.createElement("div")); tag = (TAG_NAME_REGEXP.exec(html) || ["", ""])[1].toLowerCase(); wrap = wrapMap[tag] || wrapMap._default; - tmp.innerHTML = '
 
' + - wrap[1] + html.replace(XHTML_TAG_REGEXP, "<$1>") + wrap[2]; - tmp.removeChild(tmp.firstChild); + tmp.innerHTML = wrap[1] + html.replace(XHTML_TAG_REGEXP, "<$1>") + wrap[2]; // Descend through wrappers to the right content i = wrap[0]; @@ -2313,7 +2453,7 @@ function jqLiteBuildFragment(html, context) { tmp = tmp.lastChild; } - for (j=0, jj=tmp.childNodes.length; j 1)) { + eventFns = shallowCopy(eventFns); + } - forEach(eventHandlersCopy, function(fn) { - fn.call(element, event); - }); - - // Remove monkey-patched methods (IE), - // as they would cause memory leaks in IE8. - if (msie <= 8) { - // IE7/8 does not allow to delete property on native object - event.preventDefault = null; - event.stopPropagation = null; - event.isDefaultPrevented = null; - } else { - // It shouldn't affect normal browsers (native methods are defined on prototype). - delete event.preventDefault; - delete event.stopPropagation; - delete event.isDefaultPrevented; + for (var i = 0; i < eventFnsLength; i++) { + eventFns[i].call(element, event); } }; + + // TODO: this is a hack for angularMocks/clearDataCache that makes it possible to deregister all + // events on `element` eventHandler.elem = element; return eventHandler; } @@ -2849,68 +2992,56 @@ function createEventHandler(element, events) { forEach({ removeData: jqLiteRemoveData, - dealoc: jqLiteDealoc, - - on: function onFn(element, type, fn, unsupported){ + on: function jqLiteOn(element, type, fn, unsupported){ if (isDefined(unsupported)) throw jqLiteMinErr('onargs', 'jqLite#on() does not support the `selector` or `eventData` parameters'); - var events = jqLiteExpandoStore(element, 'events'), - handle = jqLiteExpandoStore(element, 'handle'); + // Do not add event handlers to non-elements because they will not be cleaned up. + if (!jqLiteAcceptsData(element)) { + return; + } - if (!events) jqLiteExpandoStore(element, 'events', events = {}); - if (!handle) jqLiteExpandoStore(element, 'handle', handle = createEventHandler(element, events)); + var expandoStore = jqLiteExpandoStore(element, true); + var events = expandoStore.events; + var handle = expandoStore.handle; - forEach(type.split(' '), function(type){ + if (!handle) { + handle = expandoStore.handle = createEventHandler(element, events); + } + + // http://jsperf.com/string-indexof-vs-split + var types = type.indexOf(' ') >= 0 ? type.split(' ') : [type]; + var i = types.length; + + while (i--) { + type = types[i]; var eventFns = events[type]; if (!eventFns) { - if (type == 'mouseenter' || type == 'mouseleave') { - var contains = document.body.contains || document.body.compareDocumentPosition ? - function( a, b ) { - // jshint bitwise: false - var adown = a.nodeType === 9 ? a.documentElement : a, - bup = b && b.parentNode; - return a === bup || !!( bup && bup.nodeType === 1 && ( - adown.contains ? - adown.contains( bup ) : - a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 - )); - } : - function( a, b ) { - if ( b ) { - while ( (b = b.parentNode) ) { - if ( b === a ) { - return true; - } - } - } - return false; - }; - - events[type] = []; + events[type] = []; + if (type === 'mouseenter' || type === 'mouseleave') { // Refer to jQuery's implementation of mouseenter & mouseleave // Read about mouseenter and mouseleave: // http://www.quirksmode.org/js/events_mouse.html#link8 - var eventmap = { mouseleave : "mouseout", mouseenter : "mouseover"}; - onFn(element, eventmap[type], function(event) { + jqLiteOn(element, MOUSE_EVENT_MAP[type], function(event) { var target = this, related = event.relatedTarget; // For mousenter/leave call the handler if related is outside the target. // NB: No relatedTarget if the mouse left/entered the browser window - if ( !related || (related !== target && !contains(target, related)) ){ + if ( !related || (related !== target && !target.contains(related)) ){ handle(event, type); } }); } else { - addEventListenerFn(element, type, handle); - events[type] = []; + if (type !== '$destroy') { + addEventListenerFn(element, type, handle); + } } eventFns = events[type]; } eventFns.push(fn); - }); + } }, off: jqLiteOff, @@ -2955,11 +3086,15 @@ forEach({ }, append: function(element, node) { - forEach(new JQLite(node), function(child){ - if (element.nodeType === 1 || element.nodeType === 11) { - element.appendChild(child); - } - }); + var nodeType = element.nodeType; + if (nodeType !== 1 && nodeType !== 11) return; + + node = new JQLite(node); + + for (var i = 0, ii = node.length; i < ii; i++) { + var child = node[i]; + element.appendChild(child); + } }, prepend: function(element, node) { @@ -2972,7 +3107,7 @@ forEach({ }, wrap: function(element, wrapNode) { - wrapNode = jqLite(wrapNode)[0]; + wrapNode = jqLite(wrapNode).eq(0).clone()[0]; var parent = element.parentNode; if (parent) { parent.replaceChild(wrapNode, element); @@ -2980,18 +3115,21 @@ forEach({ wrapNode.appendChild(element); }, - remove: function(element) { - jqLiteDealoc(element); - var parent = element.parentNode; - if (parent) parent.removeChild(element); + remove: jqLiteRemove, + + detach: function(element) { + jqLiteRemove(element, true); }, after: function(element, newElement) { var index = element, parent = element.parentNode; - forEach(new JQLite(newElement), function(node){ + newElement = new JQLite(newElement); + + for (var i = 0, ii = newElement.length; i < ii; i++) { + var node = newElement[i]; parent.insertBefore(node, index.nextSibling); index = node; - }); + } }, addClass: jqLiteAddClass, @@ -3015,16 +3153,7 @@ forEach({ }, next: function(element) { - if (element.nextElementSibling) { - return element.nextElementSibling; - } - - // IE8 doesn't have nextElementSibling - var elm = element.nextSibling; - while (elm != null && elm.nodeType !== 1) { - elm = elm.nextSibling; - } - return elm; + return element.nextElementSibling; }, find: function(element, selector) { @@ -3037,19 +3166,39 @@ forEach({ clone: jqLiteClone, - triggerHandler: function(element, eventName, eventData) { - var eventFns = (jqLiteExpandoStore(element, 'events') || {})[eventName]; + triggerHandler: function(element, event, extraParameters) { - eventData = eventData || []; + var dummyEvent, eventFnsCopy, handlerArgs; + var eventName = event.type || event; + var expandoStore = jqLiteExpandoStore(element); + var events = expandoStore && expandoStore.events; + var eventFns = events && events[eventName]; - var event = [{ - preventDefault: noop, - stopPropagation: noop - }]; + if (eventFns) { - forEach(eventFns, function(fn) { - fn.apply(element, event.concat(eventData)); - }); + // Create a dummy event to pass to the handlers + dummyEvent = { + preventDefault: function() { this.defaultPrevented = true; }, + isDefaultPrevented: function() { return this.defaultPrevented === true; }, + stopPropagation: noop, + type: eventName, + target: element + }; + + // If a custom event was provided then extend our dummy event with it + if (event.type) { + dummyEvent = extend(dummyEvent, event); + } + + // Copy event handlers in case event handlers array is modified during execution. + eventFnsCopy = shallowCopy(eventFns); + handlerArgs = extraParameters ? [dummyEvent].concat(extraParameters) : [dummyEvent]; + + forEach(eventFnsCopy, function(fn) { + fn.apply(element, handlerArgs); + }); + + } } }, function(fn, name){ /** @@ -3057,7 +3206,8 @@ forEach({ */ JQLite.prototype[name] = function(arg1, arg2, arg3) { var value; - for(var i=0; i < this.length; i++) { + + for(var i = 0, ii = this.length; i < ii; i++) { if (isUndefined(value)) { value = fn(this[i], arg1, arg2, arg3); if (isDefined(value)) { @@ -3089,21 +3239,23 @@ forEach({ * The resulting string key is in 'type:hashKey' format. */ function hashKey(obj, nextUidFn) { - var objType = typeof obj, - key; + var key = obj && obj.$$hashKey; - if (objType == 'function' || (objType == 'object' && obj !== null)) { - if (typeof (key = obj.$$hashKey) == 'function') { - // must invoke on object to keep the right this + if (key) { + if (typeof key === 'function') { key = obj.$$hashKey(); - } else if (key === undefined) { - key = obj.$$hashKey = (nextUidFn || nextUid)(); } - } else { - key = obj; + return key; } - return objType + ':' + key; + var objType = typeof obj; + if (objType == 'function' || (objType == 'object' && obj !== null)) { + key = obj.$$hashKey = objType + ':' + (nextUidFn || nextUid)(); + } else { + key = objType + ':' + obj; + } + + return key; } /** @@ -3170,7 +3322,7 @@ HashMap.prototype = { * * // use the injector to kick off your application * // use the type inference to auto inject arguments, or use implicit injection - * $injector.invoke(function($rootScope, $compile, $document){ + * $injector.invoke(function($rootScope, $compile, $document) { * $compile($document)($rootScope); * $rootScope.$digest(); * }); @@ -3213,7 +3365,19 @@ var FN_ARG_SPLIT = /,/; var FN_ARG = /^\s*(_?)(\S+?)\1\s*$/; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; var $injectorMinErr = minErr('$injector'); -function annotate(fn) { + +function anonFn(fn) { + // For anonymous functions, showing at the very least the function signature can help in + // debugging. + var fnText = fn.toString().replace(STRIP_COMMENTS, ''), + args = fnText.match(FN_ARGS); + if (args) { + return 'function(' + (args[1] || '').replace(/[\s\r\n]+/, ' ') + ')'; + } + return 'fn'; +} + +function annotate(fn, strictDi, name) { var $inject, fnText, argDecl, @@ -3223,10 +3387,17 @@ function annotate(fn) { if (!($inject = fn.$inject)) { $inject = []; if (fn.length) { + if (strictDi) { + if (!isString(name) || !name) { + name = fn.name || anonFn(fn); + } + throw $injectorMinErr('strictdi', + '{0} is not using explicit annotation and cannot be invoked in strict mode', name); + } fnText = fn.toString().replace(STRIP_COMMENTS, ''); argDecl = fnText.match(FN_ARGS); - forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){ - arg.replace(FN_ARG, function(all, underscore, name){ + forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg) { + arg.replace(FN_ARG, function(all, underscore, name) { $inject.push(name); }); }); @@ -3261,7 +3432,7 @@ function annotate(fn) { * ```js * var $injector = angular.injector(); * expect($injector.get('$injector')).toBe($injector); - * expect($injector.invoke(function($injector){ + * expect($injector.invoke(function($injector) { * return $injector; * }).toBe($injector); * ``` @@ -3734,7 +3905,8 @@ function annotate(fn) { */ -function createInjector(modulesToLoad) { +function createInjector(modulesToLoad, strictDi) { + strictDi = (strictDi === true); var INSTANTIATING = {}, providerSuffix = 'Provider', path = [], @@ -3757,7 +3929,7 @@ function createInjector(modulesToLoad) { instanceInjector = (instanceCache.$injector = createInternalInjector(instanceCache, function(servicename) { var provider = providerInjector.get(servicename + providerSuffix); - return instanceInjector.invoke(provider.$get, provider); + return instanceInjector.invoke(provider.$get, provider, undefined, servicename); })); @@ -3820,22 +3992,27 @@ function createInjector(modulesToLoad) { // Module Loading //////////////////////////////////// function loadModules(modulesToLoad){ - var runBlocks = [], moduleFn, invokeQueue, i, ii; + var runBlocks = [], moduleFn; forEach(modulesToLoad, function(module) { if (loadedModules.get(module)) return; loadedModules.put(module, true); + function runInvokeQueue(queue) { + var i, ii; + for(i = 0, ii = queue.length; i < ii; i++) { + var invokeArgs = queue[i], + provider = providerInjector.get(invokeArgs[0]); + + provider[invokeArgs[1]].apply(provider, invokeArgs[2]); + } + } + try { if (isString(module)) { moduleFn = angularModule(module); runBlocks = runBlocks.concat(loadModules(moduleFn.requires)).concat(moduleFn._runBlocks); - - for(invokeQueue = moduleFn._invokeQueue, i = 0, ii = invokeQueue.length; i < ii; i++) { - var invokeArgs = invokeQueue[i], - provider = providerInjector.get(invokeArgs[0]); - - provider[invokeArgs[1]].apply(provider, invokeArgs[2]); - } + runInvokeQueue(moduleFn._invokeQueue); + runInvokeQueue(moduleFn._configBlocks); } else if (isFunction(module)) { runBlocks.push(providerInjector.invoke(module)); } else if (isArray(module)) { @@ -3891,9 +4068,14 @@ function createInjector(modulesToLoad) { } } - function invoke(fn, self, locals){ + function invoke(fn, self, locals, serviceName) { + if (typeof locals === 'string') { + serviceName = locals; + locals = null; + } + var args = [], - $inject = annotate(fn), + $inject = annotate(fn, strictDi, serviceName), length, i, key; @@ -3918,7 +4100,7 @@ function createInjector(modulesToLoad) { return fn.apply(self, args); } - function instantiate(Type, locals) { + function instantiate(Type, locals, serviceName) { var Constructor = function() {}, instance, returnedValue; @@ -3926,7 +4108,7 @@ function createInjector(modulesToLoad) { // e.g. someModule.factory('greeter', ['$window', function(renamed$window) {}]); Constructor.prototype = (isArray(Type) ? Type[Type.length - 1] : Type).prototype; instance = new Constructor(); - returnedValue = invoke(Type, instance, locals); + returnedValue = invoke(Type, instance, locals, serviceName); return isObject(returnedValue) || isFunction(returnedValue) ? returnedValue : instance; } @@ -3943,6 +4125,8 @@ function createInjector(modulesToLoad) { } } +createInjector.$$annotate = annotate; + /** * @ngdoc service * @name $anchorScroll @@ -3960,24 +4144,26 @@ function createInjector(modulesToLoad) { * This can be disabled by calling `$anchorScrollProvider.disableAutoScrolling()`. * * @example - + -
+
Go to bottom You're at the bottom!
- function ScrollCtrl($scope, $location, $anchorScroll) { - $scope.gotoBottom = function (){ - // set the location.hash to the id of - // the element you wish to scroll to. - $location.hash('bottom'); + angular.module('anchorScrollExample', []) + .controller('ScrollController', ['$scope', '$location', '$anchorScroll', + function ($scope, $location, $anchorScroll) { + $scope.gotoBottom = function() { + // set the location.hash to the id of + // the element you wish to scroll to. + $location.hash('bottom'); - // call $anchorScroll() - $anchorScroll(); - }; - } + // call $anchorScroll() + $anchorScroll(); + }; + }]); #scrollArea { @@ -4010,7 +4196,7 @@ function $AnchorScrollProvider() { function getFirstAnchor(list) { var result = null; forEach(list, function(element) { - if (!result && lowercase(element.nodeName) === 'a') result = element; + if (!result && nodeName_(element) === 'a') result = element; }); return result; } @@ -4125,10 +4311,19 @@ var $AnimateProvider = ['$provide', function($provide) { return this.$$classNameFilter; }; - this.$get = ['$timeout', '$$asyncCallback', function($timeout, $$asyncCallback) { + this.$get = ['$$q', '$$asyncCallback', function($$q, $$asyncCallback) { - function async(fn) { - fn && $$asyncCallback(fn); + var currentDefer; + function asyncPromise() { + // only serve one instance of a promise in order to save CPU cycles + if (!currentDefer) { + currentDefer = $$q.defer(); + $$asyncCallback(function() { + currentDefer.resolve(); + currentDefer = null; + }); + } + return currentDefer.promise; } /** @@ -4155,26 +4350,20 @@ var $AnimateProvider = ['$provide', function($provide) { * @ngdoc method * @name $animate#enter * @kind function - * @description Inserts the element into the DOM either after the `after` element or within - * the `parent` element. Once complete, the done() callback will be fired (if provided). + * @description Inserts the element into the DOM either after the `after` element or + * as the first child within the `parent` element. When the function is called a promise + * is returned that will be resolved at a later time. * @param {DOMElement} element the element which will be inserted into the DOM * @param {DOMElement} parent the parent element which will append the element as * a child (if the after element is not present) * @param {DOMElement} after the sibling element which will append the element * after itself - * @param {Function=} done callback function that will be called after the element has been - * inserted into the DOM + * @return {Promise} the animation callback promise */ - enter : function(element, parent, after, done) { - if (after) { - after.after(element); - } else { - if (!parent || !parent[0]) { - parent = after.parent(); - } - parent.append(element); - } - async(done); + enter : function(element, parent, after) { + after ? after.after(element) + : parent.prepend(element); + return asyncPromise(); }, /** @@ -4182,15 +4371,14 @@ var $AnimateProvider = ['$provide', function($provide) { * @ngdoc method * @name $animate#leave * @kind function - * @description Removes the element from the DOM. Once complete, the done() callback will be - * fired (if provided). + * @description Removes the element from the DOM. When the function is called a promise + * is returned that will be resolved at a later time. * @param {DOMElement} element the element which will be removed from the DOM - * @param {Function=} done callback function that will be called after the element has been - * removed from the DOM + * @return {Promise} the animation callback promise */ - leave : function(element, done) { + leave : function(element) { element.remove(); - async(done); + return asyncPromise(); }, /** @@ -4199,8 +4387,8 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#move * @kind function * @description Moves the position of the provided element within the DOM to be placed - * either after the `after` element or inside of the `parent` element. Once complete, the - * done() callback will be fired (if provided). + * either after the `after` element or inside of the `parent` element. When the function + * is called a promise is returned that will be resolved at a later time. * * @param {DOMElement} element the element which will be moved around within the * DOM @@ -4208,13 +4396,12 @@ var $AnimateProvider = ['$provide', function($provide) { * inserted into (if the after element is not present) * @param {DOMElement} after the sibling element where the element will be * positioned next to - * @param {Function=} done the callback function (if provided) that will be fired after the - * element has been moved to its new position + * @return {Promise} the animation callback promise */ - move : function(element, parent, after, done) { + move : function(element, parent, after) { // Do not remove element before insert. Removing will cause data associated with the // element to be dropped. Insert will implicitly do the remove. - this.enter(element, parent, after, done); + return this.enter(element, parent, after); }, /** @@ -4222,22 +4409,21 @@ var $AnimateProvider = ['$provide', function($provide) { * @ngdoc method * @name $animate#addClass * @kind function - * @description Adds the provided className CSS class value to the provided element. Once - * complete, the done() callback will be fired (if provided). + * @description Adds the provided className CSS class value to the provided element. + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have the className value * added to it * @param {string} className the CSS class which will be added to the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * className value has been added to the element + * @return {Promise} the animation callback promise */ - addClass : function(element, className, done) { - className = isString(className) ? - className : - isArray(className) ? className.join(' ') : ''; + addClass : function(element, className) { + className = !isString(className) + ? (isArray(className) ? className.join(' ') : '') + : className; forEach(element, function (element) { jqLiteAddClass(element, className); }); - async(done); + return asyncPromise(); }, /** @@ -4246,21 +4432,20 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#removeClass * @kind function * @description Removes the provided className CSS class value from the provided element. - * Once complete, the done() callback will be fired (if provided). + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have the className value * removed from it * @param {string} className the CSS class which will be removed from the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * className value has been removed from the element + * @return {Promise} the animation callback promise */ - removeClass : function(element, className, done) { - className = isString(className) ? - className : - isArray(className) ? className.join(' ') : ''; + removeClass : function(element, className) { + className = !isString(className) + ? (isArray(className) ? className.join(' ') : '') + : className; forEach(element, function (element) { jqLiteRemoveClass(element, className); }); - async(done); + return asyncPromise(); }, /** @@ -4269,23 +4454,21 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#setClass * @kind function * @description Adds and/or removes the given CSS classes to and from the element. - * Once complete, the done() callback will be fired (if provided). + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have its CSS classes changed * removed from it * @param {string} add the CSS classes which will be added to the element * @param {string} remove the CSS class which will be removed from the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * CSS classes have been set on the element + * @return {Promise} the animation callback promise */ - setClass : function(element, add, remove, done) { - forEach(element, function (element) { - jqLiteAddClass(element, add); - jqLiteRemoveClass(element, remove); - }); - async(done); + setClass : function(element, add, remove) { + this.addClass(element, add); + this.removeClass(element, remove); + return asyncPromise(); }, - enabled : noop + enabled : noop, + cancel : noop }; }]; }]; @@ -4534,6 +4717,13 @@ function Browser(window, document, $log, $sniffer) { return callback; }; + /** + * Checks whether the url has changed outside of Angular. + * Needs to be exported to be able to check for changes that have been done in sync, + * as hashchange/popstate events fire in async. + */ + self.$$checkUrlChange = fireUrlChange; + ////////////////////////////////////////////////////////////// // Misc API ////////////////////////////////////////////////////////////// @@ -4580,16 +4770,15 @@ function Browser(window, document, $log, $sniffer) { * @returns {Object} Hash of all cookies (if called without any parameter) */ self.cookies = function(name, value) { - /* global escape: false, unescape: false */ var cookieLength, cookieArray, cookie, i, index; if (name) { if (value === undefined) { - rawDocument.cookie = escape(name) + "=;path=" + cookiePath + + rawDocument.cookie = encodeURIComponent(name) + "=;path=" + cookiePath + ";expires=Thu, 01 Jan 1970 00:00:00 GMT"; } else { if (isString(value)) { - cookieLength = (rawDocument.cookie = escape(name) + '=' + escape(value) + + cookieLength = (rawDocument.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) + ';path=' + cookiePath).length + 1; // per http://www.ietf.org/rfc/rfc2109.txt browser must allow at minimum: @@ -4613,12 +4802,12 @@ function Browser(window, document, $log, $sniffer) { cookie = cookieArray[i]; index = cookie.indexOf('='); if (index > 0) { //ignore nameless cookies - name = unescape(cookie.substring(0, index)); + name = decodeURIComponent(cookie.substring(0, index)); // the first value that is seen for a cookie is the most // specific one. values for the same cookie name that // follow are for less specific paths. if (lastCookies[name] === undefined) { - lastCookies[name] = unescape(cookie.substring(index + 1)); + lastCookies[name] = decodeURIComponent(cookie.substring(index + 1)); } } } @@ -4750,8 +4939,10 @@ function $BrowserProvider(){ $scope.keys = []; $scope.cache = $cacheFactory('cacheId'); $scope.put = function(key, value) { - $scope.cache.put(key, value); - $scope.keys.push(key); + if ($scope.cache.get(key) === undefined) { + $scope.keys.push(key); + } + $scope.cache.put(key, value === undefined ? null : value); }; }]); @@ -5191,6 +5382,13 @@ function $TemplateCacheProvider() { * The directive definition object provides instructions to the {@link ng.$compile * compiler}. The attributes are: * + * #### `multiElement` + * When this property is set to true, the HTML compiler will collect DOM nodes between + * nodes with the attributes `directive-name-start` and `directive-name-end`, and group them + * together as the directive elements. It is recomended that this feature be used on directives + * which are not strictly behavioural (such as {@link api/ng.directive:ngClick ngClick}), and which + * do not manipulate or replace child nodes (such as {@link api/ng.directive:ngInclude ngInclude}). + * * #### `priority` * When there are multiple directives defined on a single DOM element, sometimes it * is necessary to specify the order in which the directives are applied. The `priority` is used @@ -5248,6 +5446,10 @@ function $TemplateCacheProvider() { * by calling the `localFn` as `localFn({amount: 22})`. * * + * #### `bindToController` + * When an isolate scope is used for a component (see above), and `controllerAs` is used, `bindToController` will + * allow a component to have its properties bound to the controller, rather than to scope. When the controller + * is instantiated, the initial values of the isolate scope bindings are already available. * * #### `controller` * Controller constructor function. The controller is instantiated before the @@ -5258,9 +5460,18 @@ function $TemplateCacheProvider() { * * `$scope` - Current scope associated with the element * * `$element` - Current element * * `$attrs` - Current attributes object for the element - * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope. - * The scope can be overridden by an optional first argument. - * `function([scope], cloneLinkingFn)`. + * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope: + * `function([scope], cloneLinkingFn, futureParentElement)`. + * * `scope`: optional argument to override the scope. + * * `cloneLinkingFn`: optional argument to create clones of the original transcluded content. + * * `futureParentElement`: + * * defines the parent to which the `cloneLinkingFn` will add the cloned elements. + * * default: `$element.parent()` resp. `$element` for `transclude:'element'` resp. `transclude:true`. + * * only needed for transcludes that are allowed to contain non html elements (e.g. SVG elements) + * and when the `cloneLinkinFn` is passed, + * as those elements need to created and cloned in a special way when they are defined outside their + * usual containers (e.g. like ``). + * * See also the `directive.templateNamespace` property. * * * #### `require` @@ -5271,9 +5482,9 @@ function $TemplateCacheProvider() { * * * (no prefix) - Locate the required controller on the current element. Throw an error if not found. * * `?` - Attempt to locate the required controller or pass `null` to the `link` fn if not found. - * * `^` - Locate the required controller by searching the element's parents. Throw an error if not found. - * * `?^` - Attempt to locate the required controller by searching the element's parents or pass `null` to the - * `link` fn if not found. + * * `^` - Locate the required controller by searching the element and its parents. Throw an error if not found. + * * `?^` - Attempt to locate the required controller by searching the element and its parents or pass + * `null` to the `link` fn if not found. * * * #### `controllerAs` @@ -5284,29 +5495,51 @@ function $TemplateCacheProvider() { * * #### `restrict` * String of subset of `EACM` which restricts the directive to a specific directive - * declaration style. If omitted, the default (attributes only) is used. + * declaration style. If omitted, the defaults (elements and attributes) are used. * - * * `E` - Element name: `` + * * `E` - Element name (default): `` * * `A` - Attribute (default): `
` * * `C` - Class: `
` * * `M` - Comment: `` * * - * #### `template` - * replace the current element with the contents of the HTML. The replacement process - * migrates all of the attributes / classes from the old element to the new one. See the - * {@link guide/directive#creating-custom-directives_creating-directives_template-expanding-directive - * Directives Guide} for an example. + * #### `templateNamespace` + * String representing the document type used by the markup in the template. + * AngularJS needs this information as those elements need to be created and cloned + * in a special way when they are defined outside their usual containers like `` and ``. * - * You can specify `template` as a string representing the template or as a function which takes - * two arguments `tElement` and `tAttrs` (described in the `compile` function api below) and - * returns a string value representing the template. + * * `html` - All root nodes in the template are HTML. Root nodes may also be + * top-level elements such as `` or ``. + * * `svg` - The root nodes in the template are SVG elements (excluding ``). + * * `math` - The root nodes in the template are MathML elements (excluding ``). + * + * If no `templateNamespace` is specified, then the namespace is considered to be `html`. + * + * #### `template` + * HTML markup that may: + * * Replace the contents of the directive's element (default). + * * Replace the directive's element itself (if `replace` is true - DEPRECATED). + * * Wrap the contents of the directive's element (if `transclude` is true). + * + * Value may be: + * + * * A string. For example `
{{delete_str}}
`. + * * A function which takes two arguments `tElement` and `tAttrs` (described in the `compile` + * function api below) and returns a string value. * * * #### `templateUrl` - * Same as `template` but the template is loaded from the specified URL. Because - * the template loading is asynchronous the compilation/linking is suspended until the template - * is loaded. + * This is similar to `template` but the template is loaded from the specified URL, asynchronously. + * + * Because template loading is asynchronous the compiler will suspend compilation of directives on that element + * for later when the template has been resolved. In the meantime it will continue to compile and link + * sibling and parent elements as though this element had not contained any directives. + * + * The compiler does not suspend the entire compilation to wait for templates to be loaded because this + * would result in the whole app "stalling" until all templates are loaded asynchronously - even in the + * case when only one deeply nested directive has `templateUrl`. + * + * Template loading is asynchronous even if the template has been preloaded into the {@link $templateCache} * * You can specify `templateUrl` as a string representing the URL or as a function which takes two * arguments `tElement` and `tAttrs` (described in the `compile` function api below) and returns @@ -5314,12 +5547,19 @@ function $TemplateCacheProvider() { * api/ng.$sce#getTrustedResourceUrl $sce.getTrustedResourceUrl}. * * - * #### `replace` ([*DEPRECATED*!], will be removed in next major release) - * specify where the template should be inserted. Defaults to `false`. + * #### `replace` ([*DEPRECATED*!], will be removed in next major release - i.e. v2.0) + * specify what the template should replace. Defaults to `false`. * - * * `true` - the template will replace the current element. - * * `false` - the template will replace the contents of the current element. + * * `true` - the template will replace the directive's element. + * * `false` - the template will replace the contents of the directive's element. * + * The replacement process migrates all of the attributes / classes from the old element to the new + * one. See the {@link guide/directive#creating-custom-directives_creating-directives_template-expanding-directive + * Directives Guide} for an example. + * + * There are very few scenarios where element replacement is required for the application function, + * the main one being reusable custom components that are used within SVG contexts + * (because SVG doesn't work with custom elements in the DOM tree). * * #### `transclude` * compile the content of the element and make it available to the directive. @@ -5333,6 +5573,11 @@ function $TemplateCacheProvider() { * * `true` - transclude the content of the directive. * * `'element'` - transclude the whole element including any directives defined at lower priority. * + *
+ * **Note:** When testing an element transclude directive you must not place the directive at the root of the + * DOM fragment that is being compiled. See {@link guide/unit-testing#testing-transclusion-directives + * Testing Transclusion Directives}. + *
* * #### `compile` * @@ -5410,10 +5655,9 @@ function $TemplateCacheProvider() { * the directives to use the controllers as a communication channel. * * * `transcludeFn` - A transclude linking function pre-bound to the correct transclusion scope. - * The scope can be overridden by an optional first argument. This is the same as the `$transclude` - * parameter of directive controllers. - * `function([scope], cloneLinkingFn)`. - * + * This is the same as the `$transclude` + * parameter of directive controllers, see there for details. + * `function([scope], cloneLinkingFn, futureParentElement)`. * * #### Pre-linking function * @@ -5422,7 +5666,14 @@ function $TemplateCacheProvider() { * * #### Post-linking function * - * Executed after the child elements are linked. It is safe to do DOM transformation in the post-linking function. + * Executed after the child elements are linked. + * + * Note that child elements that contain `templateUrl` directives will not have been compiled + * and linked since they are waiting for their template to load asynchronously and their own + * compilation and linking has been suspended until that occurs. + * + * It is safe to do DOM transformation in the post-linking function on elements that are not waiting + * for their async templates to be resolved. * * * ### Attributes @@ -5578,7 +5829,6 @@ var $compileMinErr = minErr('$compile'); /** * @ngdoc provider * @name $compileProvider - * @kind function * * @description */ @@ -5587,7 +5837,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { var hasDirectives = {}, Suffix = 'Directive', COMMENT_DIRECTIVE_REGEXP = /^\s*directive\:\s*([\d\w_\-]+)\s+(.*)$/, - CLASS_DIRECTIVE_REGEXP = /(([\d\w_\-]+)(?:\:([^;]+))?;?)/; + CLASS_DIRECTIVE_REGEXP = /(([\d\w_\-]+)(?:\:([^;]+))?;?)/, + ALL_OR_NOTHING_ATTRS = makeMap('ngSrc,ngSrcset,src,srcset'); // Ref: http://developers.whatwg.org/webappapis.html#event-handler-idl-attributes // The assumption is that future DOM event attribute names will begin with @@ -5630,7 +5881,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { directive.index = index; directive.name = directive.name || name; directive.require = directive.require || (directive.controller && directive.name); - directive.restrict = directive.restrict || 'A'; + directive.restrict = directive.restrict || 'EA'; directives.push(directive); } catch (e) { $exceptionHandler(e); @@ -5706,15 +5957,57 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }; + /** + * @ngdoc method + * @name $compileProvider#debugInfoEnabled + * + * @param {boolean=} enabled update the debugInfoEnabled state if provided, otherwise just return the + * current debugInfoEnabled state + * @returns {*} current value if used as getter or itself (chaining) if used as setter + * + * @kind function + * + * @description + * Call this method to enable/disable various debug runtime information in the compiler such as adding + * binding information and a reference to the current scope on to DOM elements. + * If enabled, the compiler will add the following to DOM elements that have been bound to the scope + * * `ng-binding` CSS class + * * `$binding` data property containing an array of the binding expressions + * + * You may want to use this in production for a significant performance boost. See + * {@link guide/production#disabling-debug-data Disabling Debug Data} for more. + * + * The default value is true. + */ + var debugInfoEnabled = true; + this.debugInfoEnabled = function(enabled) { + if(isDefined(enabled)) { + debugInfoEnabled = enabled; + return this; + } + return debugInfoEnabled; + }; + this.$get = [ - '$injector', '$interpolate', '$exceptionHandler', '$http', '$templateCache', '$parse', + '$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse', '$controller', '$rootScope', '$document', '$sce', '$animate', '$$sanitizeUri', - function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse, + function($injector, $interpolate, $exceptionHandler, $templateRequest, $parse, $controller, $rootScope, $document, $sce, $animate, $$sanitizeUri) { - var Attributes = function(element, attr) { + var Attributes = function(element, attributesToCopy) { + if (attributesToCopy) { + var keys = Object.keys(attributesToCopy); + var i, l, key; + + for (i = 0, l = keys.length; i < l; i++) { + key = keys[i]; + this[key] = attributesToCopy[key]; + } + } else { + this.$attr = {}; + } + this.$$element = element; - this.$attr = attr || {}; }; Attributes.prototype = { @@ -5769,14 +6062,13 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { */ $updateClass : function(newClasses, oldClasses) { var toAdd = tokenDifference(newClasses, oldClasses); - var toRemove = tokenDifference(oldClasses, newClasses); - - if(toAdd.length === 0) { - $animate.removeClass(this.$$element, toRemove); - } else if(toRemove.length === 0) { + if (toAdd && toAdd.length) { $animate.addClass(this.$$element, toAdd); - } else { - $animate.setClass(this.$$element, toAdd, toRemove); + } + + var toRemove = tokenDifference(oldClasses, newClasses); + if (toRemove && toRemove.length) { + $animate.removeClass(this.$$element, toRemove); } }, @@ -5794,13 +6086,19 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { //is set through this function since it may cause $updateClass to //become unstable. - var booleanKey = getBooleanAttrName(this.$$element[0], key), + var node = this.$$element[0], + booleanKey = getBooleanAttrName(node, key), + aliasedKey = getAliasedAttrName(node, key), + observer = key, normalizedVal, nodeName; if (booleanKey) { this.$$element.prop(key, value); attrName = booleanKey; + } else if(aliasedKey) { + this[aliasedKey] = value; + observer = aliasedKey; } this[key] = value; @@ -5818,8 +6116,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { nodeName = nodeName_(this.$$element); // sanitize a[href] and img[src] values - if ((nodeName === 'A' && key === 'href') || - (nodeName === 'IMG' && key === 'src')) { + if ((nodeName === 'a' && key === 'href') || + (nodeName === 'img' && key === 'src')) { this[key] = value = $$sanitizeUri(value, key === 'src'); } @@ -5833,7 +6131,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // fire observers var $$observers = this.$$observers; - $$observers && forEach($$observers[key], function(fn) { + $$observers && forEach($$observers[observer], function(fn) { try { fn(value); } catch (e) { @@ -5859,7 +6157,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { * @param {function(interpolatedValue)} fn Function that will be called whenever the interpolated value of the attribute changes. * See the {@link guide/directive#Attributes Directives} guide for more info. - * @returns {function()} the `fn` parameter. + * @returns {function()} Returns a deregistration function for this observer. */ $observe: function(key, fn) { var attrs = this, @@ -5873,10 +6171,24 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { fn(attrs[key]); } }); - return fn; + + return function() { + arrayRemove(listeners, fn); + }; } }; + + function safeAddClass($element, className) { + try { + $element.addClass(className); + } catch(e) { + // ignore, since it means that we are trying to set class on + // SVG element, where class name is read-only. + } + } + + var startSymbol = $interpolate.startSymbol(), endSymbol = $interpolate.endSymbol(), denormalizeTemplate = (startSymbol == '{{' || endSymbol == '}}') @@ -5886,6 +6198,30 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { }, NG_ATTR_BINDING = /^ngAttr[A-Z]/; + compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo($element, binding) { + var bindings = $element.data('$binding') || []; + + if (isArray(binding)) { + bindings = bindings.concat(binding); + } else { + bindings.push(binding); + } + + $element.data('$binding', bindings); + } : noop; + + compile.$$addBindingClass = debugInfoEnabled ? function $$addBindingClass($element) { + safeAddClass($element, 'ng-binding'); + } : noop; + + compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo($element, scope, isolated, noTemplate) { + var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; + $element.data(dataName, scope); + } : noop; + + compile.$$addScopeClass = debugInfoEnabled ? function $$addScopeClass($element, isolated) { + safeAddClass($element, isolated ? 'ng-isolate-scope' : 'ng-scope'); + } : noop; return compile; @@ -5902,46 +6238,57 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // not be able to attach scope data to them, so we will wrap them in forEach($compileNodes, function(node, index){ if (node.nodeType == 3 /* text node */ && node.nodeValue.match(/\S+/) /* non-empty */ ) { - $compileNodes[index] = node = jqLite(node).wrap('').parent()[0]; + $compileNodes[index] = jqLite(node).wrap('').parent()[0]; } }); var compositeLinkFn = compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority, ignoreDirective, previousCompileContext); - safeAddClass($compileNodes, 'ng-scope'); - return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn){ + compile.$$addScopeClass($compileNodes); + var namespace = null; + var namespaceAdaptedCompileNodes = $compileNodes; + var lastCompileNode; + return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn, futureParentElement){ assertArg(scope, 'scope'); + if (!namespace) { + namespace = detectNamespaceForChildElements(futureParentElement); + } + if (namespace !== 'html' && $compileNodes[0] !== lastCompileNode) { + namespaceAdaptedCompileNodes = jqLite( + wrapTemplate(namespace, jqLite('
').append($compileNodes).html()) + ); + } + // When using a directive with replace:true and templateUrl the $compileNodes + // might change, so we need to recreate the namespace adapted compileNodes. + lastCompileNode = $compileNodes[0]; + // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart // and sometimes changes the structure of the DOM. var $linkNode = cloneConnectFn - ? JQLitePrototype.clone.call($compileNodes) // IMPORTANT!!! - : $compileNodes; + ? JQLitePrototype.clone.call(namespaceAdaptedCompileNodes) // IMPORTANT!!! + : namespaceAdaptedCompileNodes; - forEach(transcludeControllers, function(instance, name) { - $linkNode.data('$' + name + 'Controller', instance); - }); - - // Attach scope only to non-text nodes. - for(var i = 0, ii = $linkNode.length; i addDirective(directives, - directiveNormalize(nodeName_(node).toLowerCase()), 'E', maxPriority, ignoreDirective); + directiveNormalize(nodeName_(node)), 'E', maxPriority, ignoreDirective); // iterate over the attributes for (var attr, name, nName, ngAttrName, value, isNgAttr, nAttrs = node.attributes, @@ -6106,10 +6468,12 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } var directiveNName = ngAttrName.replace(/(Start|End)$/, ''); - if (ngAttrName === directiveNName + 'Start') { - attrStartName = name; - attrEndName = name.substr(0, name.length - 5) + 'end'; - name = name.substr(0, name.length - 6); + if (directiveIsMultiElement(directiveNName)) { + if (ngAttrName === directiveNName + 'Start') { + attrStartName = name; + attrEndName = name.substr(0, name.length - 5) + 'end'; + name = name.substr(0, name.length - 6); + } } nName = directiveNormalize(name.toLowerCase()); @@ -6120,7 +6484,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { attrs[nName] = true; // presence means true } } - addAttrInterpolateDirective(node, directives, value, nName); + addAttrInterpolateDirective(node, directives, value, nName, isNgAttr); addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName, attrEndName); } @@ -6241,6 +6605,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { var terminalPriority = -Number.MAX_VALUE, newScopeDirective, controllerDirectives = previousCompileContext.controllerDirectives, + controllers, newIsolateScopeDirective = previousCompileContext.newIsolateScopeDirective, templateDirective = previousCompileContext.templateDirective, nonTlbTranscludeDirective = previousCompileContext.nonTlbTranscludeDirective, @@ -6273,17 +6638,25 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } if (directiveValue = directive.scope) { - newScopeDirective = newScopeDirective || directive; // skip the check for directives with async templates, we'll check the derived sync // directive when the template arrives if (!directive.templateUrl) { - assertNoDuplicate('new/isolated scope', newIsolateScopeDirective, directive, - $compileNode); if (isObject(directiveValue)) { + // This directive is trying to add an isolated scope. + // Check that there is no scope of any kind already + assertNoDuplicate('new/isolated scope', newIsolateScopeDirective || newScopeDirective, + directive, $compileNode); newIsolateScopeDirective = directive; + } else { + // This directive is trying to add a child scope. + // Check that there is no isolated scope already + assertNoDuplicate('new/isolated scope', newIsolateScopeDirective, directive, + $compileNode); } } + + newScopeDirective = newScopeDirective || directive; } directiveName = directive.name; @@ -6310,12 +6683,12 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (directiveValue == 'element') { hasElementTranscludeDirective = true; terminalPriority = directive.priority; - $template = groupScan(compileNode, attrStart, attrEnd); + $template = $compileNode; $compileNode = templateAttrs.$$element = jqLite(document.createComment(' ' + directiveName + ': ' + templateAttrs[directiveName] + ' ')); compileNode = $compileNode[0]; - replaceWith(jqCollection, jqLite(sliceArgs($template)), compileNode); + replaceWith(jqCollection, sliceArgs($template), compileNode); childTranscludeFn = compile($template, transcludeFn, terminalPriority, replaceDirective && replaceDirective.name, { @@ -6351,7 +6724,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (jqLiteIsTextNode(directiveValue)) { $template = []; } else { - $template = jqLite(trim(directiveValue)); + $template = jqLite(wrapTemplate(directive.templateNamespace, trim(directiveValue))); } compileNode = $template[0]; @@ -6424,6 +6797,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { nodeLinkFn.scope = newScopeDirective && newScopeDirective.scope === true; nodeLinkFn.transcludeOnThisElement = hasTranscludeDirective; + nodeLinkFn.elementTranscludeOnThisElement = hasElementTranscludeDirective; nodeLinkFn.templateOnThisElement = hasTemplate; nodeLinkFn.transclude = childTranscludeFn; @@ -6469,7 +6843,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { value = null; if (elementControllers && retrievalMethod === 'data') { - value = elementControllers[require]; + if (value = elementControllers[require]) { + value = value.instance; + } } value = value || $element[retrievalMethod]('$' + require + 'Controller'); @@ -6490,32 +6866,68 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { function nodeLinkFn(childLinkFn, scope, linkNode, $rootElement, boundTranscludeFn) { - var attrs, $element, i, ii, linkFn, controller, isolateScope, elementControllers = {}, transcludeFn; + var i, ii, linkFn, controller, isolateScope, elementControllers, transcludeFn, $element, + attrs; if (compileNode === linkNode) { attrs = templateAttrs; + $element = templateAttrs.$$element; } else { - attrs = shallowCopy(templateAttrs, new Attributes(jqLite(linkNode), templateAttrs.$attr)); + $element = jqLite(linkNode); + attrs = new Attributes($element, templateAttrs); + } + + if (newIsolateScopeDirective) { + isolateScope = scope.$new(true); + } + + transcludeFn = boundTranscludeFn && controllersBoundTransclude; + if (controllerDirectives) { + // TODO: merge `controllers` and `elementControllers` into single object. + controllers = {}; + elementControllers = {}; + forEach(controllerDirectives, function(directive) { + var locals = { + $scope: directive === newIsolateScopeDirective || directive.$$isolateScope ? isolateScope : scope, + $element: $element, + $attrs: attrs, + $transclude: transcludeFn + }, controllerInstance; + + controller = directive.controller; + if (controller == '@') { + controller = attrs[directive.name]; + } + + controllerInstance = $controller(controller, locals, true, directive.controllerAs); + + // For directives with element transclusion the element is a comment, + // but jQuery .data doesn't support attaching data to comment nodes as it's hard to + // clean up (http://bugs.jquery.com/ticket/8335). + // Instead, we save the controllers for the element in a local hash and attach to .data + // later, once we have the actual element. + elementControllers[directive.name] = controllerInstance; + if (!hasElementTranscludeDirective) { + $element.data('$' + directive.name + 'Controller', controllerInstance.instance); + } + + controllers[directive.name] = controllerInstance; + }); } - $element = attrs.$$element; if (newIsolateScopeDirective) { var LOCAL_REGEXP = /^\s*([@=&])(\??)\s*(\w*)\s*$/; - var $linkNode = jqLite(linkNode); - isolateScope = scope.$new(true); + compile.$$addScopeInfo($element, isolateScope, true, !(templateDirective && (templateDirective === newIsolateScopeDirective || + templateDirective === newIsolateScopeDirective.$$originalDirective))); + compile.$$addScopeClass($element, true); - if (templateDirective && (templateDirective === newIsolateScopeDirective || - templateDirective === newIsolateScopeDirective.$$originalDirective)) { - $linkNode.data('$isolateScope', isolateScope) ; - } else { - $linkNode.data('$isolateScopeNoTemplate', isolateScope); + var isolateScopeController = controllers && controllers[newIsolateScopeDirective.name]; + var isolateBindingContext = isolateScope; + if (isolateScopeController && isolateScopeController.identifier && + newIsolateScopeDirective.bindToController === true) { + isolateBindingContext = isolateScopeController.instance; } - - - - safeAddClass($linkNode, 'ng-isolate-scope'); - forEach(newIsolateScopeDirective.scope, function(definition, scopeName) { var match = definition.match(LOCAL_REGEXP) || [], attrName = match[3] || scopeName, @@ -6536,7 +6948,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if( attrs[attrName] ) { // If the attribute has been provided then we trigger an interpolation to ensure // the value is there for use in the link fn - isolateScope[scopeName] = $interpolate(attrs[attrName])(scope); + isolateBindingContext[scopeName] = $interpolate(attrs[attrName])(scope); } break; @@ -6548,35 +6960,35 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (parentGet.literal) { compare = equals; } else { - compare = function(a,b) { return a === b; }; + compare = function(a,b) { return a === b || (a !== a && b !== b); }; } parentSet = parentGet.assign || function() { // reset the change, or we will throw this exception on every $digest - lastValue = isolateScope[scopeName] = parentGet(scope); + lastValue = isolateBindingContext[scopeName] = parentGet(scope); throw $compileMinErr('nonassign', "Expression '{0}' used with directive '{1}' is non-assignable!", attrs[attrName], newIsolateScopeDirective.name); }; - lastValue = isolateScope[scopeName] = parentGet(scope); - isolateScope.$watch(function parentValueWatch() { - var parentValue = parentGet(scope); - if (!compare(parentValue, isolateScope[scopeName])) { + lastValue = isolateBindingContext[scopeName] = parentGet(scope); + var unwatch = scope.$watch($parse(attrs[attrName], function parentValueWatch(parentValue) { + if (!compare(parentValue, isolateBindingContext[scopeName])) { // we are out of sync and need to copy if (!compare(parentValue, lastValue)) { // parent changed and it has precedence - isolateScope[scopeName] = parentValue; + isolateBindingContext[scopeName] = parentValue; } else { // if the parent can be assigned then do so - parentSet(scope, parentValue = isolateScope[scopeName]); + parentSet(scope, parentValue = isolateBindingContext[scopeName]); } } return lastValue = parentValue; - }, null, parentGet.literal); + }), null, parentGet.literal); + isolateScope.$on('$destroy', unwatch); break; case '&': parentGet = $parse(attrs[attrName]); - isolateScope[scopeName] = function(locals) { + isolateBindingContext[scopeName] = function(locals) { return parentGet(scope, locals); }; break; @@ -6589,47 +7001,23 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }); } - transcludeFn = boundTranscludeFn && controllersBoundTransclude; - if (controllerDirectives) { - forEach(controllerDirectives, function(directive) { - var locals = { - $scope: directive === newIsolateScopeDirective || directive.$$isolateScope ? isolateScope : scope, - $element: $element, - $attrs: attrs, - $transclude: transcludeFn - }, controllerInstance; - - controller = directive.controller; - if (controller == '@') { - controller = attrs[directive.name]; - } - - controllerInstance = $controller(controller, locals); - // For directives with element transclusion the element is a comment, - // but jQuery .data doesn't support attaching data to comment nodes as it's hard to - // clean up (http://bugs.jquery.com/ticket/8335). - // Instead, we save the controllers for the element in a local hash and attach to .data - // later, once we have the actual element. - elementControllers[directive.name] = controllerInstance; - if (!hasElementTranscludeDirective) { - $element.data('$' + directive.name + 'Controller', controllerInstance); - } - - if (directive.controllerAs) { - locals.$scope[directive.controllerAs] = controllerInstance; - } + if (controllers) { + forEach(controllers, function(controller) { + controller(); }); + controllers = null; } // PRELINKING for(i = 0, ii = preLinkFns.length; i < ii; i++) { - try { - linkFn = preLinkFns[i]; - linkFn(linkFn.isolateScope ? isolateScope : scope, $element, attrs, - linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), transcludeFn); - } catch (e) { - $exceptionHandler(e, startingTag($element)); - } + linkFn = preLinkFns[i]; + invokeLinkFn(linkFn, + linkFn.isolateScope ? isolateScope : scope, + $element, + attrs, + linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), + transcludeFn + ); } // RECURSION @@ -6643,21 +7031,24 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // POSTLINKING for(i = postLinkFns.length - 1; i >= 0; i--) { - try { - linkFn = postLinkFns[i]; - linkFn(linkFn.isolateScope ? isolateScope : scope, $element, attrs, - linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), transcludeFn); - } catch (e) { - $exceptionHandler(e, startingTag($element)); - } + linkFn = postLinkFns[i]; + invokeLinkFn(linkFn, + linkFn.isolateScope ? isolateScope : scope, + $element, + attrs, + linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), + transcludeFn + ); } // This is the function that is injected as `$transclude`. - function controllersBoundTransclude(scope, cloneAttachFn) { + // Note: all arguments are optional! + function controllersBoundTransclude(scope, cloneAttachFn, futureParentElement) { var transcludeControllers; - // no scope passed - if (arguments.length < 2) { + // No scope passed in: + if (!isScope(scope)) { + futureParentElement = cloneAttachFn; cloneAttachFn = scope; scope = undefined; } @@ -6665,8 +7056,10 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (hasElementTranscludeDirective) { transcludeControllers = elementControllers; } - - return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers); + if (!futureParentElement) { + futureParentElement = hasElementTranscludeDirective ? $element.parent() : $element; + } + return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement); } } } @@ -6716,6 +7109,27 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } + /** + * looks up the directive and returns true if it is a multi-element directive, + * and therefore requires DOM nodes between -start and -end markers to be grouped + * together. + * + * @param {string} name name of the directive to look up. + * @returns true if directive was registered as multi-element. + */ + function directiveIsMultiElement(name) { + if (hasDirectives.hasOwnProperty(name)) { + for(var directive, directives = $injector.get(name + Suffix), + i = 0, ii = directives.length; i'+template+''; + return wrapper.childNodes[0].childNodes; + default: + return template; + } + } function getTrustedContext(node, attrNormalizedName) { @@ -6926,22 +7351,22 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { var tag = nodeName_(node); // maction[xlink:href] can source SVG. It's not limited to . if (attrNormalizedName == "xlinkHref" || - (tag == "FORM" && attrNormalizedName == "action") || - (tag != "IMG" && (attrNormalizedName == "src" || + (tag == "form" && attrNormalizedName == "action") || + (tag != "img" && (attrNormalizedName == "src" || attrNormalizedName == "ngSrc"))) { return $sce.RESOURCE_URL; } } - function addAttrInterpolateDirective(node, directives, value, name) { + function addAttrInterpolateDirective(node, directives, value, name, allOrNothing) { var interpolateFn = $interpolate(value, true); // no interpolation found -> ignore if (!interpolateFn) return; - if (name === "multiple" && nodeName_(node) === "SELECT") { + if (name === "multiple" && nodeName_(node) === "select") { throw $compileMinErr("selmulti", "Binding to the 'multiple' attribute is not supported. Element: {0}", startingTag(node)); @@ -6962,15 +7387,18 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // we need to interpolate again, in case the attribute value has been updated // (e.g. by another directive's compile function) - interpolateFn = $interpolate(attr[name], true, getTrustedContext(node, name)); + interpolateFn = $interpolate(attr[name], true, getTrustedContext(node, name), + ALL_OR_NOTHING_ATTRS[name] || allOrNothing); // if attribute was updated so that there is no interpolation going on we don't want to // register any observers if (!interpolateFn) return; - // TODO(i): this should likely be attr.$set(name, iterpolateFn(scope) so that we reset the - // actual attr value + // initialize attr object so that it's ready in case we need the value for isolate + // scope initialization, otherwise the value would not be available from isolate + // directive's linking fn during linking phase attr[name] = interpolateFn(scope); + ($$observers[name] || ($$observers[name] = [])).$$inter = true; (attr.$$observers && attr.$$observers[name].$$scope || scope). $watch(interpolateFn, function interpolateFnWatchAction(newValue, oldValue) { @@ -7023,6 +7451,13 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } } $rootElement.length -= removeCount - 1; + + // If the replaced element is also the jQuery .context then replace it + // .context is a deprecated jQuery api, so we should set it only when jQuery set it + // http://api.jquery.com/context/ + if ($rootElement.context === firstElementToRemove) { + $rootElement.context = newNode; + } break; } } @@ -7031,9 +7466,33 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (parent) { parent.replaceChild(newNode, firstElementToRemove); } + + // TODO(perf): what's this document fragment for? is it needed? can we at least reuse it? var fragment = document.createDocumentFragment(); fragment.appendChild(firstElementToRemove); - newNode[jqLite.expando] = firstElementToRemove[jqLite.expando]; + + // Copy over user data (that includes Angular's $scope etc.). Don't copy private + // data here because there's no public interface in jQuery to do that and copying over + // event listeners (which is the main use of private data) wouldn't work anyway. + jqLite(newNode).data(jqLite(firstElementToRemove).data()); + + // Remove data of the replaced element. We cannot just call .remove() + // on the element it since that would deallocate scope that is needed + // for the new node. Instead, remove the data "manually". + if (!jQuery) { + delete jqLite.cache[firstElementToRemove[jqLite.expando]]; + } else { + // jQuery 2.x doesn't expose the data storage. Use jQuery.cleanData to clean up after + // the replaced element. The cleanData version monkey-patched by Angular would cause + // the scope to be trashed and we do need the very same scope to work with the new + // element. However, we cannot just cache the non-patched version and use it here as + // that would break if another library patches the method after Angular does (one + // example is jQuery UI). Instead, set a flag indicating scope destroying should be + // skipped this one time. + skipDestroyOnNextJQueryCleanData = true; + jQuery.cleanData([firstElementToRemove]); + } + for (var k = 1, kk = elementsToRemove.length; k < kk; k++) { var element = elementsToRemove[k]; jqLite(element).remove(); // must do this way to clean up expando @@ -7049,6 +7508,15 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { function cloneAndAnnotateFn(fn, annotation) { return extend(function() { return fn.apply(null, arguments); }, fn, annotation); } + + + function invokeLinkFn(linkFn, scope, $element, attrs, controllers, transcludeFn) { + try { + linkFn(scope, $element, attrs, controllers, transcludeFn); + } catch(e) { + $exceptionHandler(e, startingTag($element)); + } + } }]; } @@ -7085,8 +7553,10 @@ function directiveNormalize(name) { /** * @ngdoc property * @name $compile.directive.Attributes#$attr - * @returns {object} A map of DOM element attribute names to the normalized name. This is - * needed to do reverse lookup from normalized name back to actual name. + * + * @description + * A map of DOM element attribute names to the normalized name. This is + * needed to do reverse lookup from normalized name back to actual name. */ @@ -7154,6 +7624,7 @@ function tokenDifference(str1, str2) { */ function $ControllerProvider() { var controllers = {}, + globals = false, CNTRL_REG = /^(\S+)(\s+as\s+(\w+))?$/; @@ -7174,6 +7645,15 @@ function $ControllerProvider() { } }; + /** + * @ngdoc method + * @name $controllerProvider#allowGlobals + * @description If called, allows `$controller` to find controller constructors on `window` + */ + this.allowGlobals = function() { + globals = true; + }; + this.$get = ['$injector', '$window', function($injector, $window) { @@ -7188,7 +7668,8 @@ function $ControllerProvider() { * * * check if a controller with given name is registered via `$controllerProvider` * * check if evaluating the string on the current scope returns a constructor - * * check `window[constructor]` on the global `window` object + * * if $controllerProvider#allowGlobals, check `window[constructor]` on the global + * `window` object (not recommended) * * @param {Object} locals Injection locals for Controller. * @return {Object} Instance of given controller. @@ -7199,34 +7680,78 @@ function $ControllerProvider() { * It's just a simple call to {@link auto.$injector $injector}, but extracted into * a service, so that one can override this service with [BC version](https://gist.github.com/1649788). */ - return function(expression, locals) { + return function(expression, locals, later, ident) { + // PRIVATE API: + // param `later` --- indicates that the controller's constructor is invoked at a later time. + // If true, $controller will allocate the object with the correct + // prototype chain, but will not invoke the controller until a returned + // callback is invoked. + // param `ident` --- An optional label which overrides the label parsed from the controller + // expression, if any. var instance, match, constructor, identifier; + later = later === true; + if (ident && isString(ident)) { + identifier = ident; + } if(isString(expression)) { match = expression.match(CNTRL_REG), constructor = match[1], - identifier = match[3]; + identifier = identifier || match[3]; expression = controllers.hasOwnProperty(constructor) ? controllers[constructor] - : getter(locals.$scope, constructor, true) || getter($window, constructor, true); + : getter(locals.$scope, constructor, true) || + (globals ? getter($window, constructor, true) : undefined); assertArgFn(expression, constructor, true); } - instance = $injector.instantiate(expression, locals); + if (later) { + // Instantiate controller later: + // This machinery is used to create an instance of the object before calling the + // controller's constructor itself. + // + // This allows properties to be added to the controller before the constructor is + // invoked. Primarily, this is used for isolate scope bindings in $compile. + // + // This feature is not intended for use by applications, and is thus not documented + // publicly. + var Constructor = function() {}; + Constructor.prototype = (isArray(expression) ? + expression[expression.length - 1] : expression).prototype; + instance = new Constructor(); - if (identifier) { - if (!(locals && typeof locals.$scope === 'object')) { - throw minErr('$controller')('noscp', - "Cannot export controller '{0}' as '{1}'! No $scope object provided via `locals`.", - constructor || expression.name, identifier); + if (identifier) { + addIdentifier(locals, identifier, instance, constructor || expression.name); } - locals.$scope[identifier] = instance; + return extend(function() { + $injector.invoke(expression, instance, locals, constructor); + return instance; + }, { + instance: instance, + identifier: identifier + }); + } + + instance = $injector.instantiate(expression, locals, constructor); + + if (identifier) { + addIdentifier(locals, identifier, instance, constructor || expression.name); } return instance; }; + + function addIdentifier(locals, identifier, instance, name) { + if (!(locals && isObject(locals.$scope))) { + throw minErr('$controller')('noscp', + "Cannot export controller '{0}' as '{1}'! No $scope object provided via `locals`.", + name, identifier); + } + + locals.$scope[identifier] = instance; + } }]; } @@ -7318,11 +7843,7 @@ function parseHeaders(headers) { val = trim(line.substr(i + 1)); if (key) { - if (parsed[key]) { - parsed[key] += ', ' + val; - } else { - parsed[key] = val; - } + parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val; } }); @@ -7448,18 +7969,40 @@ function $HttpProvider() { xsrfHeaderName: 'X-XSRF-TOKEN' }; + var useApplyAsync = false; + /** + * @ngdoc method + * @name $httpProvider#useApplyAsync + * @description + * + * Configure $http service to combine processing of multiple http responses received at around + * the same time via {@link ng.$rootScope#applyAsync $rootScope.$applyAsync}. This can result in + * significant performance improvement for bigger applications that make many HTTP requests + * concurrently (common during application bootstrap). + * + * Defaults to false. If no value is specifed, returns the current configured value. + * + * @param {boolean=} value If true, when requests are loaded, they will schedule a deferred + * "apply" on the next tick, giving time for subsequent requests in a roughly ~10ms window + * to load and share the same digest cycle. + * + * @returns {boolean|Object} If a value is specified, returns the $httpProvider for chaining. + * otherwise, returns the current configured value. + **/ + this.useApplyAsync = function(value) { + if (isDefined(value)) { + useApplyAsync = !!value; + return this; + } + return useApplyAsync; + }; + /** * Are ordered by request, i.e. they are applied in the same order as the * array, on request, but reverse order, on response. */ var interceptorFactories = this.interceptors = []; - /** - * For historical reasons, response interceptors are ordered by the order in which - * they are applied to the response. (This is the opposite of interceptorFactories) - */ - var responseInterceptorFactories = this.responseInterceptors = []; - this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector', function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) { @@ -7477,27 +8020,6 @@ function $HttpProvider() { ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory)); }); - forEach(responseInterceptorFactories, function(interceptorFactory, index) { - var responseFn = isString(interceptorFactory) - ? $injector.get(interceptorFactory) - : $injector.invoke(interceptorFactory); - - /** - * Response interceptors go before "around" interceptors (no real reason, just - * had to pick one.) But they are already reversed, so we can't use unshift, hence - * the splice. - */ - reversedInterceptors.splice(index, 0, { - response: function(response) { - return responseFn($q.when(response)); - }, - responseError: function(response) { - return responseFn($q.reject(response)); - } - }); - }); - - /** * @ngdoc service * @kind function @@ -7524,7 +8046,7 @@ function $HttpProvider() { * it is important to familiarize yourself with these APIs and the guarantees they provide. * * - * # General usage + * ## General usage * The `$http` service is a function which takes a single argument — a configuration object — * that is used to generate an HTTP request and returns a {@link ng.$q promise} * with two $http specific methods: `success` and `error`. @@ -7551,7 +8073,7 @@ function $HttpProvider() { * XMLHttpRequest will transparently follow it, meaning that the error callback will not be * called for such responses. * - * # Writing Unit Tests that use $http + * ## Writing Unit Tests that use $http * When unit testing (using {@link ngMock ngMock}), it is necessary to call * {@link ngMock.$httpBackend#flush $httpBackend.flush()} to flush each pending * request using trained responses. @@ -7562,7 +8084,7 @@ function $HttpProvider() { * $httpBackend.flush(); * ``` * - * # Shortcut methods + * ## Shortcut methods * * Shortcut methods are also available. All shortcut methods require passing in the URL, and * request data must be passed in for POST/PUT requests. @@ -7580,9 +8102,10 @@ function $HttpProvider() { * - {@link ng.$http#put $http.put} * - {@link ng.$http#delete $http.delete} * - {@link ng.$http#jsonp $http.jsonp} + * - {@link ng.$http#patch $http.patch} * * - * # Setting HTTP Headers + * ## Setting HTTP Headers * * The $http service will automatically add certain HTTP headers to all requests. These defaults * can be fully configured by accessing the `$httpProvider.defaults.headers` configuration @@ -7613,36 +8136,69 @@ function $HttpProvider() { * calling `$http(config)`, which overrides the defaults without changing them globally. * * - * # Transforming Requests and Responses + * ## Transforming Requests and Responses * - * Both requests and responses can be transformed using transform functions. By default, Angular - * applies these transformations: + * Both requests and responses can be transformed using transformation functions: `transformRequest` + * and `transformResponse`. These properties can be a single function that returns + * the transformed value (`{function(data, headersGetter)`) or an array of such transformation functions, + * which allows you to `push` or `unshift` a new transformation function into the transformation chain. * - * Request transformations: + * ### Default Transformations + * + * The `$httpProvider` provider and `$http` service expose `defaults.transformRequest` and + * `defaults.transformResponse` properties. If a request does not provide its own transformations + * then these will be applied. + * + * You can augment or replace the default transformations by modifying these properties by adding to or + * replacing the array. + * + * Angular provides the following default transformations: + * + * Request transformations (`$httpProvider.defaults.transformRequest` and `$http.defaults.transformRequest`): * * - If the `data` property of the request configuration object contains an object, serialize it * into JSON format. * - * Response transformations: + * Response transformations (`$httpProvider.defaults.transformResponse` and `$http.defaults.transformResponse`): * * - If XSRF prefix is detected, strip it (see Security Considerations section below). * - If JSON response is detected, deserialize it using a JSON parser. * - * To globally augment or override the default transforms, modify the - * `$httpProvider.defaults.transformRequest` and `$httpProvider.defaults.transformResponse` - * properties. These properties are by default an array of transform functions, which allows you - * to `push` or `unshift` a new transformation function into the transformation chain. You can - * also decide to completely override any default transformations by assigning your - * transformation functions to these properties directly without the array wrapper. These defaults - * are again available on the $http factory at run-time, which may be useful if you have run-time - * services you wish to be involved in your transformations. * - * Similarly, to locally override the request/response transforms, augment the - * `transformRequest` and/or `transformResponse` properties of the configuration object passed + * ### Overriding the Default Transformations Per Request + * + * If you wish override the request/response transformations only for a single request then provide + * `transformRequest` and/or `transformResponse` properties on the configuration object passed * into `$http`. * + * Note that if you provide these properties on the config object the default transformations will be + * overwritten. If you wish to augment the default transformations then you must include them in your + * local transformation array. * - * # Caching + * The following code demonstrates adding a new response transformation to be run after the default response + * transformations have been run. + * + * ```js + * function appendTransform(defaults, transform) { + * + * // We can't guarantee that the default transformation is an array + * defaults = angular.isArray(defaults) ? defaults : [defaults]; + * + * // Append the new transformation to the defaults + * return defaults.concat(transform); + * } + * + * $http({ + * url: '...', + * method: 'GET', + * transformResponse: appendTransform($http.defaults.transformResponse, function(value) { + * return doTransform(value); + * }) + * }); + * ``` + * + * + * ## Caching * * To enable caching, set the request configuration `cache` property to `true` (to use default * cache) or to a custom cache object (built with {@link ng.$cacheFactory `$cacheFactory`}). @@ -7665,7 +8221,7 @@ function $HttpProvider() { * If you set the default cache to `false` then only requests that specify their own custom * cache object will be cached. * - * # Interceptors + * ## Interceptors * * Before you start creating interceptors, be sure to understand the * {@link ng.$q $q and deferred/promise APIs}. @@ -7750,52 +8306,7 @@ function $HttpProvider() { * }); * ``` * - * # Response interceptors (DEPRECATED) - * - * Before you start creating interceptors, be sure to understand the - * {@link ng.$q $q and deferred/promise APIs}. - * - * For purposes of global error handling, authentication or any kind of synchronous or - * asynchronous preprocessing of received responses, it is desirable to be able to intercept - * responses for http requests before they are handed over to the application code that - * initiated these requests. The response interceptors leverage the {@link ng.$q - * promise apis} to fulfil this need for both synchronous and asynchronous preprocessing. - * - * The interceptors are service factories that are registered with the $httpProvider by - * adding them to the `$httpProvider.responseInterceptors` array. The factory is called and - * injected with dependencies (if specified) and returns the interceptor — a function that - * takes a {@link ng.$q promise} and returns the original or a new promise. - * - * ```js - * // register the interceptor as a service - * $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) { - * return function(promise) { - * return promise.then(function(response) { - * // do something on success - * return response; - * }, function(response) { - * // do something on error - * if (canRecover(response)) { - * return responseOrNewPromise - * } - * return $q.reject(response); - * }); - * } - * }); - * - * $httpProvider.responseInterceptors.push('myHttpInterceptor'); - * - * - * // register the interceptor via an anonymous factory - * $httpProvider.responseInterceptors.push(function($q, dependency1, dependency2) { - * return function(promise) { - * // same as above - * } - * }); - * ``` - * - * - * # Security Considerations + * ## Security Considerations * * When designing web applications, consider security threats from: * @@ -7806,7 +8317,7 @@ function $HttpProvider() { * pre-configured with strategies that address these issues, but for this to work backend server * cooperation is required. * - * ## JSON Vulnerability Protection + * ### JSON Vulnerability Protection * * A [JSON vulnerability](http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx) * allows third party website to turn your JSON resource URL into @@ -7828,7 +8339,7 @@ function $HttpProvider() { * Angular will strip the prefix, before processing the JSON. * * - * ## Cross Site Request Forgery (XSRF) Protection + * ### Cross Site Request Forgery (XSRF) Protection * * [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) is a technique by which * an unauthorized site can gain your user's private data. Angular provides a mechanism @@ -7844,7 +8355,7 @@ function $HttpProvider() { * that only JavaScript running on your domain could have sent the request. The token must be * unique for each user and must be verifiable by the server (to prevent the JavaScript from * making up its own tokens). We recommend that the token is a digest of your site's - * authentication cookie with a [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) + * authentication cookie with a [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) * for added security. * * The name of the headers can be specified using the xsrfHeaderName and xsrfCookieName @@ -7870,10 +8381,12 @@ function $HttpProvider() { * `{function(data, headersGetter)|Array.}` – * transform function or an array of such functions. The transform function takes the http * request body and headers and returns its transformed (typically serialized) version. + * See {@link #overriding-the-default-transformations-per-request Overriding the Default Transformations} * - **transformResponse** – * `{function(data, headersGetter)|Array.}` – * transform function or an array of such functions. The transform function takes the http * response body and headers and returns its transformed (typically deserialized) version. + * See {@link #overriding-the-default-transformations-per-request Overriding the Default Transformations} * - **cache** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the * GET request, otherwise if a cache instance built with * {@link ng.$cacheFactory $cacheFactory}, this cache will be used for @@ -7881,7 +8394,7 @@ function $HttpProvider() { * - **timeout** – `{number|Promise}` – timeout in milliseconds, or {@link ng.$q promise} * that should abort the request when resolved. * - **withCredentials** - `{boolean}` - whether to set the `withCredentials` flag on the - * XHR object. See [requests with credentials]https://developer.mozilla.org/en/http_access_control#section_5 + * XHR object. See [requests with credentials](https://developer.mozilla.org/docs/Web/HTTP/Access_control_CORS#Requests_with_credentials) * for more information. * - **responseType** - `{string}` - see * [requestType](https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType). @@ -8159,7 +8672,7 @@ function $HttpProvider() { * Shortcut method to perform `JSONP` request. * * @param {string} url Relative or absolute URL specifying the destination of the request. - * Should contain `JSON_CALLBACK` string. + * The name of the callback should be the string `JSON_CALLBACK`. * @param {Object=} config Optional configuration object * @returns {HttpPromise} Future object */ @@ -8190,7 +8703,20 @@ function $HttpProvider() { * @param {Object=} config Optional configuration object * @returns {HttpPromise} Future object */ - createShortMethodsWithData('post', 'put'); + + /** + * @ngdoc method + * @name $http#patch + * + * @description + * Shortcut method to perform `PATCH` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {*} data Request content + * @param {Object=} config Optional configuration object + * @returns {HttpPromise} Future object + */ + createShortMethodsWithData('post', 'put', 'patch'); /** * @ngdoc property @@ -8250,7 +8776,8 @@ function $HttpProvider() { promise.then(removePendingReq, removePendingReq); - if ((config.cache || defaults.cache) && config.cache !== false && config.method == 'GET') { + if ((config.cache || defaults.cache) && config.cache !== false && + (config.method === 'GET' || config.method === 'JSONP')) { cache = isObject(config.cache) ? config.cache : isObject(defaults.cache) ? defaults.cache : defaultCache; @@ -8259,7 +8786,7 @@ function $HttpProvider() { if (cache) { cachedResp = cache.get(url); if (isDefined(cachedResp)) { - if (cachedResp.then) { + if (isPromiseLike(cachedResp)) { // cached request has already been sent, but there is no response yet cachedResp.then(removePendingReq, removePendingReq); return cachedResp; @@ -8311,8 +8838,16 @@ function $HttpProvider() { } } - resolvePromise(response, status, headersString, statusText); - if (!$rootScope.$$phase) $rootScope.$apply(); + function resolveHttpPromise() { + resolvePromise(response, status, headersString, statusText); + } + + if (useApplyAsync) { + $rootScope.$applyAsync(resolveHttpPromise); + } else { + resolveHttpPromise(); + if (!$rootScope.$$phase) $rootScope.$apply(); + } } @@ -8334,34 +8869,36 @@ function $HttpProvider() { function removePendingReq() { - var idx = indexOf($http.pendingRequests, config); + var idx = $http.pendingRequests.indexOf(config); if (idx !== -1) $http.pendingRequests.splice(idx, 1); } } function buildUrl(url, params) { - if (!params) return url; - var parts = []; - forEachSorted(params, function(value, key) { - if (value === null || isUndefined(value)) return; - if (!isArray(value)) value = [value]; + if (!params) return url; + var parts = []; + forEachSorted(params, function(value, key) { + if (value === null || isUndefined(value)) return; + if (!isArray(value)) value = [value]; - forEach(value, function(v) { - if (isObject(v)) { - v = toJson(v); - } - parts.push(encodeUriQuery(key) + '=' + - encodeUriQuery(v)); - }); - }); - if(parts.length > 0) { - url += ((url.indexOf('?') == -1) ? '?' : '&') + parts.join('&'); + forEach(value, function(v) { + if (isObject(v)) { + if (isDate(v)){ + v = v.toISOString(); + } else { + v = toJson(v); + } } - return url; - } - - + parts.push(encodeUriQuery(key) + '=' + + encodeUriQuery(v)); + }); + }); + if(parts.length > 0) { + url += ((url.indexOf('?') == -1) ? '?' : '&') + parts.join('&'); + } + return url; + } }]; } @@ -8497,7 +9034,7 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc if (timeout > 0) { var timeoutId = $browserDefer(timeoutRequest, timeout); - } else if (timeout && timeout.then) { + } else if (isPromiseLike(timeout)) { timeout.then(timeoutRequest); } @@ -8561,18 +9098,6 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc addEventListenerFn(script, "load", callback); addEventListenerFn(script, "error", callback); - - if (msie <= 8) { - script.onreadystatechange = function() { - if (isString(script.readyState) && /loaded|complete/.test(script.readyState)) { - script.onreadystatechange = null; - callback({ - type: 'load' - }); - } - }; - } - rawDocument.body.appendChild(script); return callback; } @@ -8583,7 +9108,6 @@ var $interpolateMinErr = minErr('$interpolate'); /** * @ngdoc provider * @name $interpolateProvider - * @kind function * * @description * @@ -8659,7 +9183,13 @@ function $InterpolateProvider() { this.$get = ['$parse', '$exceptionHandler', '$sce', function($parse, $exceptionHandler, $sce) { var startSymbolLength = startSymbol.length, - endSymbolLength = endSymbol.length; + endSymbolLength = endSymbol.length, + escapedStartRegexp = new RegExp(startSymbol.replace(/./g, escape), 'g'), + escapedEndRegexp = new RegExp(endSymbol.replace(/./g, escape), 'g'); + + function escape(ch) { + return '\\\\\\' + ch; + } /** * @ngdoc service @@ -8683,6 +9213,62 @@ function $InterpolateProvider() { * expect(exp({name:'Angular'}).toEqual('Hello ANGULAR!'); * ``` * + * `$interpolate` takes an optional fourth argument, `allOrNothing`. If `allOrNothing` is + * `true`, the interpolation function will return `undefined` unless all embedded expressions + * evaluate to a value other than `undefined`. + * + * ```js + * var $interpolate = ...; // injected + * var context = {greeting: 'Hello', name: undefined }; + * + * // default "forgiving" mode + * var exp = $interpolate('{{greeting}} {{name}}!'); + * expect(exp(context)).toEqual('Hello !'); + * + * // "allOrNothing" mode + * exp = $interpolate('{{greeting}} {{name}}!', false, null, true); + * expect(exp(context)).toBeUndefined(); + * context.name = 'Angular'; + * expect(exp(context)).toEqual('Hello Angular!'); + * ``` + * + * `allOrNothing` is useful for interpolating URLs. `ngSrc` and `ngSrcset` use this behavior. + * + * ####Escaped Interpolation + * $interpolate provides a mechanism for escaping interpolation markers. Start and end markers + * can be escaped by preceding each of their characters with a REVERSE SOLIDUS U+005C (backslash). + * It will be rendered as a regular start/end marker, and will not be interpreted as an expression + * or binding. + * + * This enables web-servers to prevent script injection attacks and defacing attacks, to some + * degree, while also enabling code examples to work without relying on the + * {@link ng.directive:ngNonBindable ngNonBindable} directive. + * + * **For security purposes, it is strongly encouraged that web servers escape user-supplied data, + * replacing angle brackets (<, >) with &lt; and &gt; respectively, and replacing all + * interpolation start/end markers with their escaped counterparts.** + * + * Escaped interpolation markers are only replaced with the actual interpolation markers in rendered + * output when the $interpolate service processes the text. So, for HTML elements interpolated + * by {@link ng.$compile $compile}, or otherwise interpolated with the `mustHaveExpression` parameter + * set to `true`, the interpolated text must contain an unescaped interpolation expression. As such, + * this is typically useful only when user-data is used in rendering a template from the server, or + * when otherwise untrusted data is used by a directive. + * + * + * + *
+ *

{{apptitle}}: \{\{ username = "defaced value"; \}\} + *

+ *

{{username}} attempts to inject code which will deface the + * application, but fails to accomplish their task, because the server has correctly + * escaped the interpolation start/end markers with REVERSE SOLIDUS U+005C (backslash) + * characters.

+ *

Instead, the result of the attempted script injection is visible, and can be removed + * from the database by an administrator.

+ *
+ *
+ *
* * @param {string} text The text with markup to interpolate. * @param {boolean=} mustHaveExpression if set to true then the interpolation string must have @@ -8692,103 +9278,141 @@ function $InterpolateProvider() { * result through {@link ng.$sce#getTrusted $sce.getTrusted(interpolatedResult, * trustedContext)} before returning it. Refer to the {@link ng.$sce $sce} service that * provides Strict Contextual Escaping for details. + * @param {boolean=} allOrNothing if `true`, then the returned function returns undefined + * unless all embedded expressions evaluate to a value other than `undefined`. * @returns {function(context)} an interpolation function which is used to compute the * interpolated string. The function has these parameters: * - * * `context`: an object against which any expressions embedded in the strings are evaluated - * against. - * + * - `context`: evaluation context for all expressions embedded in the interpolated text */ - function $interpolate(text, mustHaveExpression, trustedContext) { + function $interpolate(text, mustHaveExpression, trustedContext, allOrNothing) { + allOrNothing = !!allOrNothing; var startIndex, endIndex, index = 0, - parts = [], - length = text.length, - hasInterpolation = false, - fn, + expressions = [], + parseFns = [], + textLength = text.length, exp, - concat = []; + concat = [], + expressionPositions = []; - while(index < length) { + while(index < textLength) { if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) && ((endIndex = text.indexOf(endSymbol, startIndex + startSymbolLength)) != -1) ) { - (index != startIndex) && parts.push(text.substring(index, startIndex)); - parts.push(fn = $parse(exp = text.substring(startIndex + startSymbolLength, endIndex))); - fn.exp = exp; + if (index !== startIndex) { + concat.push(unescapeText(text.substring(index, startIndex))); + } + exp = text.substring(startIndex + startSymbolLength, endIndex); + expressions.push(exp); + parseFns.push($parse(exp, parseStringifyInterceptor)); index = endIndex + endSymbolLength; - hasInterpolation = true; + expressionPositions.push(concat.length); + concat.push(''); } else { - // we did not find anything, so we have to add the remainder to the parts array - (index != length) && parts.push(text.substring(index)); - index = length; + // we did not find an interpolation, so we have to add the remainder to the separators array + if (index !== textLength) { + concat.push(unescapeText(text.substring(index))); + } + break; } } - if (!(length = parts.length)) { - // we added, nothing, must have been an empty string. - parts.push(''); - length = 1; - } - // Concatenating expressions makes it hard to reason about whether some combination of // concatenated values are unsafe to use and could easily lead to XSS. By requiring that a // single expression be used for iframe[src], object[src], etc., we ensure that the value // that's used is assigned or constructed by some JS code somewhere that is more testable or // make it obvious that you bound the value to some user controlled value. This helps reduce // the load when auditing for XSS issues. - if (trustedContext && parts.length > 1) { + if (trustedContext && concat.length > 1) { throw $interpolateMinErr('noconcat', "Error while interpolating: {0}\nStrict Contextual Escaping disallows " + "interpolations that concatenate multiple expressions when a trusted value is " + "required. See http://docs.angularjs.org/api/ng.$sce", text); } - if (!mustHaveExpression || hasInterpolation) { - concat.length = length; - fn = function(context) { - try { - for(var i = 0, ii = length, part; i * *
@@ -8961,10 +9585,10 @@ function $IntervalProvider() { function interval(fn, delay, count, invokeApply) { var setInterval = $window.setInterval, clearInterval = $window.clearInterval, - deferred = $q.defer(), - promise = deferred.promise, iteration = 0, - skipApply = (isDefined(invokeApply) && !invokeApply); + skipApply = (isDefined(invokeApply) && !invokeApply), + deferred = (skipApply ? $$q : $q).defer(), + promise = deferred.promise; count = isDefined(count) ? count : 0; @@ -9212,21 +9836,32 @@ function LocationHtml5Url(appBase, basePrefix) { this.$$absUrl = appBaseNoFile + this.$$url.substr(1); // first char is always '/' }; - this.$$rewrite = function(url) { + this.$$parseLinkUrl = function(url, relHref) { + if (relHref && relHref[0] === '#') { + // special case for links to hash fragments: + // keep the old url and only replace the hash fragment + this.hash(relHref.slice(1)); + return true; + } var appUrl, prevAppUrl; + var rewrittenUrl; if ( (appUrl = beginsWith(appBase, url)) !== undefined ) { prevAppUrl = appUrl; if ( (appUrl = beginsWith(basePrefix, appUrl)) !== undefined ) { - return appBaseNoFile + (beginsWith('/', appUrl) || appUrl); + rewrittenUrl = appBaseNoFile + (beginsWith('/', appUrl) || appUrl); } else { - return appBase + prevAppUrl; + rewrittenUrl = appBase + prevAppUrl; } } else if ( (appUrl = beginsWith(appBaseNoFile, url)) !== undefined ) { - return appBaseNoFile + appUrl; + rewrittenUrl = appBaseNoFile + appUrl; } else if (appBaseNoFile == url + '/') { - return appBaseNoFile; + rewrittenUrl = appBaseNoFile; } + if (rewrittenUrl) { + this.$$parse(rewrittenUrl); + } + return !!rewrittenUrl; }; } @@ -9316,10 +9951,12 @@ function LocationHashbangUrl(appBase, hashPrefix) { this.$$absUrl = appBase + (this.$$url ? hashPrefix + this.$$url : ''); }; - this.$$rewrite = function(url) { + this.$$parseLinkUrl = function(url, relHref) { if(stripHash(appBase) == stripHash(url)) { - return url; + this.$$parse(url); + return true; } + return false; }; } @@ -9339,16 +9976,28 @@ function LocationHashbangInHtml5Url(appBase, hashPrefix) { var appBaseNoFile = stripFile(appBase); - this.$$rewrite = function(url) { + this.$$parseLinkUrl = function(url, relHref) { + if (relHref && relHref[0] === '#') { + // special case for links to hash fragments: + // keep the old url and only replace the hash fragment + this.hash(relHref.slice(1)); + return true; + } + + var rewrittenUrl; var appUrl; if ( appBase == stripHash(url) ) { - return url; + rewrittenUrl = url; } else if ( (appUrl = beginsWith(appBaseNoFile, url)) ) { - return appBase + hashPrefix + appUrl; + rewrittenUrl = appBase + hashPrefix + appUrl; } else if ( appBaseNoFile === url + '/') { - return appBaseNoFile; + rewrittenUrl = appBaseNoFile; } + if (rewrittenUrl) { + this.$$parse(rewrittenUrl); + } + return !!rewrittenUrl; }; this.$$compose = function() { @@ -9405,17 +10054,16 @@ LocationHashbangInHtml5Url.prototype = * Change path, search and hash, when called with parameter and return `$location`. * * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`) - * @param {string=} replace The path that will be changed * @return {string} url */ - url: function(url, replace) { + url: function(url) { if (isUndefined(url)) return this.$$url; var match = PATH_MATCH.exec(url); if (match[1]) this.path(decodeURIComponent(match[1])); if (match[2] || match[1]) this.search(match[3] || ''); - this.hash(match[5] || '', replace); + this.hash(match[5] || ''); return this; }, @@ -9473,10 +10121,11 @@ LocationHashbangInHtml5Url.prototype = * Note: Path should always begin with forward slash (/), this method will add the forward slash * if it is missing. * - * @param {string=} path New path + * @param {(string|number)=} path New path * @return {string} path */ path: locationGetterSetter('$$path', function(path) { + path = path ? path.toString() : ''; return path.charAt(0) == '/' ? path : '/' + path; }), @@ -9512,7 +10161,7 @@ LocationHashbangInHtml5Url.prototype = * If the argument is a hash object containing an array of values, these values will be encoded * as duplicate search parameters in the url. * - * @param {(string|Array|boolean)=} paramValue If `search` is a string, then `paramValue` + * @param {(string|Number|Array|boolean)=} paramValue If `search` is a string or number, then `paramValue` * will override only a single search property. * * If `paramValue` is an array, it will override the property of the `search` component of @@ -9531,7 +10180,8 @@ LocationHashbangInHtml5Url.prototype = case 0: return this.$$search; case 1: - if (isString(search)) { + if (isString(search) || isNumber(search)) { + search = search.toString(); this.$$search = parseKeyValue(search); } else if (isObject(search)) { // remove object undefined or null properties @@ -9568,10 +10218,12 @@ LocationHashbangInHtml5Url.prototype = * * Change hash fragment when called with parameter and return `$location`. * - * @param {string=} hash New hash fragment + * @param {(string|number)=} hash New hash fragment * @return {string} hash */ - hash: locationGetterSetter('$$hash', identity), + hash: locationGetterSetter('$$hash', function(hash) { + return hash ? hash.toString() : ''; + }), /** * @ngdoc method @@ -9711,6 +10363,10 @@ function $LocationProvider(){ appBase; if (html5Mode) { + if (!baseHref) { + throw $locationMinErr('nobase', + "$location in HTML5 mode requires a tag to be present!"); + } appBase = serverBase(initialUrl) + (baseHref || '/'); LocationMode = $sniffer.history ? LocationHtml5Url : LocationHashbangInHtml5Url; } else { @@ -9718,7 +10374,9 @@ function $LocationProvider(){ LocationMode = LocationHashbangUrl; } $location = new LocationMode(appBase, '#' + hashPrefix); - $location.$$parse($location.$$rewrite(initialUrl)); + $location.$$parseLinkUrl(initialUrl, initialUrl); + + var IGNORE_URI_REGEXP = /^\s*(javascript|mailto):/i; $rootElement.on('click', function(event) { // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser) @@ -9729,12 +10387,15 @@ function $LocationProvider(){ var elm = jqLite(event.target); // traverse the DOM up to find first A tag - while (lowercase(elm[0].nodeName) !== 'a') { + while (nodeName_(elm[0]) !== 'a') { // ignore rewriting if no A tag (reached root element, or no parent - removed from document) if (elm[0] === $rootElement[0] || !(elm = elm.parent())[0]) return; } var absHref = elm.prop('href'); + // get the actual href attribute - see + // http://msdn.microsoft.com/en-us/library/ie/dd347148(v=vs.85).aspx + var relHref = elm.attr('href') || elm.attr('xlink:href'); if (isObject(absHref) && absHref.toString() === '[object SVGAnimatedString]') { // SVGAnimatedString.animVal should be identical to SVGAnimatedString.baseVal, unless during @@ -9742,49 +10403,18 @@ function $LocationProvider(){ absHref = urlResolve(absHref.animVal).href; } - // Make relative links work in HTML5 mode for legacy browsers (or at least IE8 & 9) - // The href should be a regular url e.g. /link/somewhere or link/somewhere or ../somewhere or - // somewhere#anchor or http://example.com/somewhere - if (LocationMode === LocationHashbangInHtml5Url) { - // get the actual href attribute - see - // http://msdn.microsoft.com/en-us/library/ie/dd347148(v=vs.85).aspx - var href = elm.attr('href') || elm.attr('xlink:href'); + // Ignore when url is started with javascript: or mailto: + if (IGNORE_URI_REGEXP.test(absHref)) return; - if (href.indexOf('://') < 0) { // Ignore absolute URLs - var prefix = '#' + hashPrefix; - if (href[0] == '/') { - // absolute path - replace old path - absHref = appBase + prefix + href; - } else if (href[0] == '#') { - // local anchor - absHref = appBase + prefix + ($location.path() || '/') + href; - } else { - // relative path - join with current path - var stack = $location.path().split("/"), - parts = href.split("/"); - for (var i=0; i 1; i++) { key = ensureSafeMemberName(element.shift(), fullExp); - var propertyObj = obj[key]; + var propertyObj = ensureSafeObject(obj[key], fullExp); if (!propertyObj) { propertyObj = {}; obj[key] = propertyObj; } obj = propertyObj; - if (obj.then && options.unwrapPromises) { - promiseWarning(fullExp); - if (!("$$v" in obj)) { - (function(promise) { - promise.then(function(val) { promise.$$v = val; }); } - )(obj); - } - if (obj.$$v === undefined) { - obj.$$v = {}; - } - obj = obj.$$v; - } } key = ensureSafeMemberName(element.shift(), fullExp); - ensureSafeObject(obj, fullExp); ensureSafeObject(obj[key], fullExp); obj[key] = setValue; return setValue; } -var getterFnCache = {}; +var getterFnCache = createMap(); /** * Implementation of the "Black Hole" variant from: * - http://jsperf.com/angularjs-parse-getter/4 * - http://jsperf.com/path-evaluation-simplified/7 */ -function cspSafeGetterFn(key0, key1, key2, key3, key4, fullExp, options) { +function cspSafeGetterFn(key0, key1, key2, key3, key4, fullExp) { ensureSafeMemberName(key0, fullExp); ensureSafeMemberName(key1, fullExp); ensureSafeMemberName(key2, fullExp); ensureSafeMemberName(key3, fullExp); ensureSafeMemberName(key4, fullExp); - return !options.unwrapPromises - ? function cspSafeGetter(scope, locals) { - var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope; + return function cspSafeGetter(scope, locals) { + var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope; - if (pathVal == null) return pathVal; - pathVal = pathVal[key0]; + if (pathVal == null) return pathVal; + pathVal = pathVal[key0]; - if (!key1) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key1]; + if (!key1) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key1]; - if (!key2) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key2]; + if (!key2) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key2]; - if (!key3) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key3]; + if (!key3) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key3]; - if (!key4) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key4]; + if (!key4) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key4]; - return pathVal; - } - : function cspSafePromiseEnabledGetter(scope, locals) { - var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope, - promise; - - if (pathVal == null) return pathVal; - - pathVal = pathVal[key0]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key1) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key1]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key2) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key2]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key3) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key3]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key4) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key4]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - return pathVal; - }; + return pathVal; + }; } function getterFn(path, options, fullExp) { - // Check whether the cache has this getter already. - // We can use hasOwnProperty directly on the cache because we ensure, - // see below, that the cache never stores a path called 'hasOwnProperty' - if (getterFnCache.hasOwnProperty(path)) { - return getterFnCache[path]; - } + var fn = getterFnCache[path]; + + if (fn) return fn; var pathKeys = path.split('.'), - pathKeysLength = pathKeys.length, - fn; + pathKeysLength = pathKeys.length; // http://jsperf.com/angularjs-parse-getter/6 if (options.csp) { if (pathKeysLength < 6) { - fn = cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4], fullExp, - options); + fn = cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4], fullExp); } else { fn = function(scope, locals) { var i = 0, val; do { val = cspSafeGetterFn(pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], - pathKeys[i++], fullExp, options)(scope, locals); + pathKeys[i++], fullExp)(scope, locals); locals = undefined; // clear after first iteration scope = val; @@ -11023,7 +11547,7 @@ function getterFn(path, options, fullExp) { }; } } else { - var code = 'var p;\n'; + var code = ''; forEach(pathKeys, function(key, index) { ensureSafeMemberName(key, fullExp); code += 'if(s == null) return undefined;\n' + @@ -11031,35 +11555,23 @@ function getterFn(path, options, fullExp) { // we simply dereference 's' on any .dot notation ? 's' // but if we are first then we check locals first, and if so read it first - : '((k&&k.hasOwnProperty("' + key + '"))?k:s)') + '["' + key + '"]' + ';\n' + - (options.unwrapPromises - ? 'if (s && s.then) {\n' + - ' pw("' + fullExp.replace(/(["\r\n])/g, '\\$1') + '");\n' + - ' if (!("$$v" in s)) {\n' + - ' p=s;\n' + - ' p.$$v = undefined;\n' + - ' p.then(function(v) {p.$$v=v;});\n' + - '}\n' + - ' s=s.$$v\n' + - '}\n' - : ''); + : '((l&&l.hasOwnProperty("' + key + '"))?l:s)') + '.' + key + ';\n'; }); code += 'return s;'; /* jshint -W054 */ - var evaledFnGetter = new Function('s', 'k', 'pw', code); // s=scope, k=locals, pw=promiseWarning + var evaledFnGetter = new Function('s', 'l', code); // s=scope, l=locals /* jshint +W054 */ evaledFnGetter.toString = valueFn(code); - fn = options.unwrapPromises ? function(scope, locals) { - return evaledFnGetter(scope, locals, promiseWarning); - } : evaledFnGetter; + evaledFnGetter.assign = function(self, value) { + return setter(self, path, value, path); + }; + + fn = evaledFnGetter; } - // Only cache the value if it's not going to mess up the cache object - // This is more performant that using Object.prototype.hasOwnProperty.call - if (path !== 'hasOwnProperty') { - getterFnCache[path] = fn; - } + fn.sharedGetter = true; + getterFnCache[path] = fn; return fn; } @@ -11109,142 +11621,146 @@ function getterFn(path, options, fullExp) { /** * @ngdoc provider * @name $parseProvider - * @kind function * * @description * `$parseProvider` can be used for configuring the default behavior of the {@link ng.$parse $parse} * service. */ function $ParseProvider() { - var cache = {}; + var cache = createMap(); var $parseOptions = { - csp: false, - unwrapPromises: false, - logPromiseWarnings: true + csp: false }; - /** - * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. - * - * @ngdoc method - * @name $parseProvider#unwrapPromises - * @description - * - * **This feature is deprecated, see deprecation notes below for more info** - * - * If set to true (default is false), $parse will unwrap promises automatically when a promise is - * found at any part of the expression. In other words, if set to true, the expression will always - * result in a non-promise value. - * - * While the promise is unresolved, it's treated as undefined, but once resolved and fulfilled, - * the fulfillment value is used in place of the promise while evaluating the expression. - * - * **Deprecation notice** - * - * This is a feature that didn't prove to be wildly useful or popular, primarily because of the - * dichotomy between data access in templates (accessed as raw values) and controller code - * (accessed as promises). - * - * In most code we ended up resolving promises manually in controllers anyway and thus unifying - * the model access there. - * - * Other downsides of automatic promise unwrapping: - * - * - when building components it's often desirable to receive the raw promises - * - adds complexity and slows down expression evaluation - * - makes expression code pre-generation unattractive due to the amount of code that needs to be - * generated - * - makes IDE auto-completion and tool support hard - * - * **Warning Logs** - * - * If the unwrapping is enabled, Angular will log a warning about each expression that unwraps a - * promise (to reduce the noise, each expression is logged only once). To disable this logging use - * `$parseProvider.logPromiseWarnings(false)` api. - * - * - * @param {boolean=} value New value. - * @returns {boolean|self} Returns the current setting when used as getter and self if used as - * setter. - */ - this.unwrapPromises = function(value) { - if (isDefined(value)) { - $parseOptions.unwrapPromises = !!value; - return this; - } else { - return $parseOptions.unwrapPromises; - } - }; - - - /** - * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. - * - * @ngdoc method - * @name $parseProvider#logPromiseWarnings - * @description - * - * Controls whether Angular should log a warning on any encounter of a promise in an expression. - * - * The default is set to `true`. - * - * This setting applies only if `$parseProvider.unwrapPromises` setting is set to true as well. - * - * @param {boolean=} value New value. - * @returns {boolean|self} Returns the current setting when used as getter and self if used as - * setter. - */ - this.logPromiseWarnings = function(value) { - if (isDefined(value)) { - $parseOptions.logPromiseWarnings = value; - return this; - } else { - return $parseOptions.logPromiseWarnings; - } - }; - - - this.$get = ['$filter', '$sniffer', '$log', function($filter, $sniffer, $log) { + this.$get = ['$filter', '$sniffer', function($filter, $sniffer) { $parseOptions.csp = $sniffer.csp; - promiseWarning = function promiseWarningFn(fullExp) { - if (!$parseOptions.logPromiseWarnings || promiseWarningCache.hasOwnProperty(fullExp)) return; - promiseWarningCache[fullExp] = true; - $log.warn('[$parse] Promise found in the expression `' + fullExp + '`. ' + - 'Automatic unwrapping of promises in Angular expressions is deprecated.'); - }; + function wrapSharedExpression(exp) { + var wrapped = exp; - return function(exp) { - var parsedExpression; + if (exp.sharedGetter) { + wrapped = function $parseWrapper(self, locals) { + return exp(self, locals); + }; + wrapped.literal = exp.literal; + wrapped.constant = exp.constant; + wrapped.assign = exp.assign; + } + + return wrapped; + } + + return function $parse(exp, interceptorFn) { + var parsedExpression, oneTime, cacheKey; switch (typeof exp) { case 'string': + cacheKey = exp = exp.trim(); - if (cache.hasOwnProperty(exp)) { - return cache[exp]; + parsedExpression = cache[cacheKey]; + + if (!parsedExpression) { + if (exp.charAt(0) === ':' && exp.charAt(1) === ':') { + oneTime = true; + exp = exp.substring(2); + } + + var lexer = new Lexer($parseOptions); + var parser = new Parser(lexer, $filter, $parseOptions); + parsedExpression = parser.parse(exp); + + if (parsedExpression.constant) { + parsedExpression.$$watchDelegate = constantWatchDelegate; + } else if (oneTime) { + //oneTime is not part of the exp passed to the Parser so we may have to + //wrap the parsedExpression before adding a $$watchDelegate + parsedExpression = wrapSharedExpression(parsedExpression); + parsedExpression.$$watchDelegate = parsedExpression.literal ? + oneTimeLiteralWatchDelegate : oneTimeWatchDelegate; + } + + cache[cacheKey] = parsedExpression; } - - var lexer = new Lexer($parseOptions); - var parser = new Parser(lexer, $filter, $parseOptions); - parsedExpression = parser.parse(exp); - - if (exp !== 'hasOwnProperty') { - // Only cache the value if it's not going to mess up the cache object - // This is more performant that using Object.prototype.hasOwnProperty.call - cache[exp] = parsedExpression; - } - - return parsedExpression; + return addInterceptor(parsedExpression, interceptorFn); case 'function': - return exp; + return addInterceptor(exp, interceptorFn); default: - return noop; + return addInterceptor(noop, interceptorFn); } }; + + function oneTimeWatchDelegate(scope, listener, objectEquality, parsedExpression) { + var unwatch, lastValue; + return unwatch = scope.$watch(function oneTimeWatch(scope) { + return parsedExpression(scope); + }, function oneTimeListener(value, old, scope) { + lastValue = value; + if (isFunction(listener)) { + listener.apply(this, arguments); + } + if (isDefined(value)) { + scope.$$postDigest(function () { + if (isDefined(lastValue)) { + unwatch(); + } + }); + } + }, objectEquality); + } + + function oneTimeLiteralWatchDelegate(scope, listener, objectEquality, parsedExpression) { + var unwatch; + return unwatch = scope.$watch(function oneTimeWatch(scope) { + return parsedExpression(scope); + }, function oneTimeListener(value, old, scope) { + if (isFunction(listener)) { + listener.call(this, value, old, scope); + } + if (isAllDefined(value)) { + scope.$$postDigest(function () { + if(isAllDefined(value)) unwatch(); + }); + } + }, objectEquality); + + function isAllDefined(value) { + var allDefined = true; + forEach(value, function (val) { + if (!isDefined(val)) allDefined = false; + }); + return allDefined; + } + } + + function constantWatchDelegate(scope, listener, objectEquality, parsedExpression) { + var unwatch; + return unwatch = scope.$watch(function constantWatch(scope) { + return parsedExpression(scope); + }, function constantListener(value, old, scope) { + if (isFunction(listener)) { + listener.apply(this, arguments); + } + unwatch(); + }, objectEquality); + } + + function addInterceptor(parsedExpression, interceptorFn) { + if (!interceptorFn) return parsedExpression; + + var fn = function interceptedExpression(scope, locals) { + var value = parsedExpression(scope, locals); + var result = interceptorFn(value, scope, locals); + // we only return the interceptor's result if the + // initial value is defined (for bind-once) + return isDefined(value) ? result : value; + }; + fn.$$watchDelegate = parsedExpression.$$watchDelegate; + return fn; + } }]; } @@ -11256,6 +11772,46 @@ function $ParseProvider() { * @description * A promise/deferred implementation inspired by [Kris Kowal's Q](https://github.com/kriskowal/q). * + * $q can be used in two fashions --- one which is more similar to Kris Kowal's Q or jQuery's Deferred + * implementations, and the other which resembles ES6 promises to some degree. + * + * # $q constructor + * + * The streamlined ES6 style promise is essentially just using $q as a constructor which takes a `resolver` + * function as the first argument. This is similar to the native Promise implementation from ES6 Harmony, + * see [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). + * + * While the constructor-style use is supported, not all of the supporting methods from ES6 Harmony promises are + * available yet. + * + * It can be used like so: + * + * ```js + * return $q(function(resolve, reject) { + * // perform some asynchronous operation, resolve or reject the promise when appropriate. + * setInterval(function() { + * if (pollStatus > 0) { + * resolve(polledValue); + * } else if (pollStatus < 0) { + * reject(polledValue); + * } else { + * pollStatus = pollAgain(function(value) { + * polledValue = value; + * }); + * } + * }, 10000); + * }). + * then(function(value) { + * // handle success + * }, function(reason) { + * // handle failure + * }); + * ``` + * + * Note: progress/notify callbacks are not currently supported via the ES6-style interface. + * + * However, the more traditional CommonJS-style usage is still available, and documented below. + * * [The CommonJS Promise proposal](http://wiki.commonjs.org/wiki/Promises) describes a promise as an * interface for interacting with an object that represents the result of an action that is * performed asynchronously, and may or may not be finished at any given point in time. @@ -11302,7 +11858,6 @@ function $ParseProvider() { * For more on this please see the [Q documentation](https://github.com/kriskowal/q) especially the * section on serial or parallel joining of promises. * - * * # The Deferred API * * A new instance of deferred is constructed by calling `$q.defer()`. @@ -11343,7 +11898,7 @@ function $ParseProvider() { * * This method *returns a new promise* which is resolved or rejected via the return value of the * `successCallback`, `errorCallback`. It also notifies via the return value of the - * `notifyCallback` method. The promise can not be resolved or rejected from the notifyCallback + * `notifyCallback` method. The promise cannot be resolved or rejected from the notifyCallback * method. * * - `catch(errorCallback)` – shorthand for `promise.then(null, errorCallback)` @@ -11411,6 +11966,12 @@ function $ParseProvider() { * expect(resolvedValue).toEqual(123); * })); * ``` + * + * @param {function(function, function)} resolver Function which is responsible for resolving or + * rejecting the newly created promise. The first parameter is a function which resolves the + * promise, the second parameter is a function which rejects the promise. + * + * @returns {Promise} The newly created promise. */ function $QProvider() { @@ -11421,20 +11982,40 @@ function $QProvider() { }]; } +function $$QProvider() { + this.$get = ['$browser', '$exceptionHandler', function($browser, $exceptionHandler) { + return qFactory(function(callback) { + $browser.defer(callback); + }, $exceptionHandler); + }]; +} /** * Constructs a promise manager. * - * @param {function(Function)} nextTick Function for executing functions in the next turn. + * @param {function(function)} nextTick Function for executing functions in the next turn. * @param {function(...*)} exceptionHandler Function into which unexpected exceptions are passed for * debugging purposes. * @returns {object} Promise manager. */ function qFactory(nextTick, exceptionHandler) { + var $qMinErr = minErr('$q', TypeError); + function callOnce(self, resolveFn, rejectFn) { + var called = false; + function wrap(fn) { + return function(value) { + if (called) return; + called = true; + fn.call(self, value); + }; + } + + return [wrap(resolveFn), wrap(rejectFn)]; + } /** * @ngdoc method - * @name $q#defer + * @name ng.$q#defer * @kind function * * @description @@ -11443,152 +12024,148 @@ function qFactory(nextTick, exceptionHandler) { * @returns {Deferred} Returns a new instance of deferred. */ var defer = function() { - var pending = [], - value, deferred; - - deferred = { - - resolve: function(val) { - if (pending) { - var callbacks = pending; - pending = undefined; - value = ref(val); - - if (callbacks.length) { - nextTick(function() { - var callback; - for (var i = 0, ii = callbacks.length; i < ii; i++) { - callback = callbacks[i]; - value.then(callback[0], callback[1], callback[2]); - } - }); - } - } - }, - - - reject: function(reason) { - deferred.resolve(createInternalRejectedPromise(reason)); - }, - - - notify: function(progress) { - if (pending) { - var callbacks = pending; - - if (pending.length) { - nextTick(function() { - var callback; - for (var i = 0, ii = callbacks.length; i < ii; i++) { - callback = callbacks[i]; - callback[2](progress); - } - }); - } - } - }, - - - promise: { - then: function(callback, errback, progressback) { - var result = defer(); - - var wrappedCallback = function(value) { - try { - result.resolve((isFunction(callback) ? callback : defaultCallback)(value)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } - }; - - var wrappedErrback = function(reason) { - try { - result.resolve((isFunction(errback) ? errback : defaultErrback)(reason)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } - }; - - var wrappedProgressback = function(progress) { - try { - result.notify((isFunction(progressback) ? progressback : defaultCallback)(progress)); - } catch(e) { - exceptionHandler(e); - } - }; - - if (pending) { - pending.push([wrappedCallback, wrappedErrback, wrappedProgressback]); - } else { - value.then(wrappedCallback, wrappedErrback, wrappedProgressback); - } - - return result.promise; - }, - - "catch": function(callback) { - return this.then(null, callback); - }, - - "finally": function(callback) { - - function makePromise(value, resolved) { - var result = defer(); - if (resolved) { - result.resolve(value); - } else { - result.reject(value); - } - return result.promise; - } - - function handleCallback(value, isResolved) { - var callbackOutput = null; - try { - callbackOutput = (callback ||defaultCallback)(); - } catch(e) { - return makePromise(e, false); - } - if (callbackOutput && isFunction(callbackOutput.then)) { - return callbackOutput.then(function() { - return makePromise(value, isResolved); - }, function(error) { - return makePromise(error, false); - }); - } else { - return makePromise(value, isResolved); - } - } - - return this.then(function(value) { - return handleCallback(value, true); - }, function(error) { - return handleCallback(error, false); - }); - } - } - }; - - return deferred; + return new Deferred(); }; + function Promise() { + this.$$state = { status: 0 }; + } - var ref = function(value) { - if (value && isFunction(value.then)) return value; - return { - then: function(callback) { - var result = defer(); + Promise.prototype = { + then: function(onFulfilled, onRejected, progressBack) { + var result = new Deferred(); + + this.$$state.pending = this.$$state.pending || []; + this.$$state.pending.push([result, onFulfilled, onRejected, progressBack]); + if (this.$$state.status > 0) scheduleProcessQueue(this.$$state); + + return result.promise; + }, + + "catch": function(callback) { + return this.then(null, callback); + }, + + "finally": function(callback, progressBack) { + return this.then(function(value) { + return handleCallback(value, true, callback); + }, function(error) { + return handleCallback(error, false, callback); + }, progressBack); + } + }; + + //Faster, more basic than angular.bind http://jsperf.com/angular-bind-vs-custom-vs-native + function simpleBind(context, fn) { + return function(value) { + fn.call(context, value); + }; + } + + function processQueue(state) { + var fn, promise, pending; + + pending = state.pending; + state.processScheduled = false; + state.pending = undefined; + for (var i = 0, ii = pending.length; i < ii; ++i) { + promise = pending[i][0]; + fn = pending[i][state.status]; + try { + if (isFunction(fn)) { + promise.resolve(fn(state.value)); + } else if (state.status === 1) { + promise.resolve(state.value); + } else { + promise.reject(state.value); + } + } catch(e) { + promise.reject(e); + exceptionHandler(e); + } + } + } + + function scheduleProcessQueue(state) { + if (state.processScheduled || !state.pending) return; + state.processScheduled = true; + nextTick(function() { processQueue(state); }); + } + + function Deferred() { + this.promise = new Promise(); + //Necessary to support unbound execution :/ + this.resolve = simpleBind(this, this.resolve); + this.reject = simpleBind(this, this.reject); + this.notify = simpleBind(this, this.notify); + } + + Deferred.prototype = { + resolve: function(val) { + if (this.promise.$$state.status) return; + if (val === this.promise) { + this.$$reject($qMinErr( + 'qcycle', + "Expected promise to be resolved with value other than itself '{0}'", + val)); + } + else { + this.$$resolve(val); + } + + }, + + $$resolve: function(val) { + var then, fns; + + fns = callOnce(this, this.$$resolve, this.$$reject); + try { + if ((isObject(val) || isFunction(val))) then = val && val.then; + if (isFunction(then)) { + this.promise.$$state.status = -1; + then.call(val, fns[0], fns[1], this.notify); + } else { + this.promise.$$state.value = val; + this.promise.$$state.status = 1; + scheduleProcessQueue(this.promise.$$state); + } + } catch(e) { + fns[1](e); + exceptionHandler(e); + } + }, + + reject: function(reason) { + if (this.promise.$$state.status) return; + this.$$reject(reason); + }, + + $$reject: function(reason) { + this.promise.$$state.value = reason; + this.promise.$$state.status = 2; + scheduleProcessQueue(this.promise.$$state); + }, + + notify: function(progress) { + var callbacks = this.promise.$$state.pending; + + if ((this.promise.$$state.status <= 0) && callbacks && callbacks.length) { nextTick(function() { - result.resolve(callback(value)); + var callback, result; + for (var i = 0, ii = callbacks.length; i < ii; i++) { + result = callbacks[i][0]; + callback = callbacks[i][3]; + try { + result.notify(isFunction(callback) ? callback(progress) : progress); + } catch(e) { + exceptionHandler(e); + } + } }); - return result.promise; } - }; + } }; - /** * @ngdoc method * @name $q#reject @@ -11626,28 +12203,38 @@ function qFactory(nextTick, exceptionHandler) { * @returns {Promise} Returns a promise that was already resolved as rejected with the `reason`. */ var reject = function(reason) { - var result = defer(); + var result = new Deferred(); result.reject(reason); return result.promise; }; - var createInternalRejectedPromise = function(reason) { - return { - then: function(callback, errback) { - var result = defer(); - nextTick(function() { - try { - result.resolve((isFunction(errback) ? errback : defaultErrback)(reason)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } - }); - return result.promise; - } - }; + var makePromise = function makePromise(value, resolved) { + var result = new Deferred(); + if (resolved) { + result.resolve(value); + } else { + result.reject(value); + } + return result.promise; }; + var handleCallback = function handleCallback(value, isResolved, callback) { + var callbackOutput = null; + try { + if (isFunction(callback)) callbackOutput = callback(); + } catch(e) { + return makePromise(e, false); + } + if (isPromiseLike(callbackOutput)) { + return callbackOutput.then(function() { + return makePromise(value, isResolved); + }, function(error) { + return makePromise(error, false); + }); + } else { + return makePromise(value, isResolved); + } + }; /** * @ngdoc method @@ -11662,65 +12249,14 @@ function qFactory(nextTick, exceptionHandler) { * @param {*} value Value or a promise * @returns {Promise} Returns a promise of the passed value or promise */ - var when = function(value, callback, errback, progressback) { - var result = defer(), - done; - var wrappedCallback = function(value) { - try { - return (isFunction(callback) ? callback : defaultCallback)(value); - } catch (e) { - exceptionHandler(e); - return reject(e); - } - }; - var wrappedErrback = function(reason) { - try { - return (isFunction(errback) ? errback : defaultErrback)(reason); - } catch (e) { - exceptionHandler(e); - return reject(e); - } - }; - - var wrappedProgressback = function(progress) { - try { - return (isFunction(progressback) ? progressback : defaultCallback)(progress); - } catch (e) { - exceptionHandler(e); - } - }; - - nextTick(function() { - ref(value).then(function(value) { - if (done) return; - done = true; - result.resolve(ref(value).then(wrappedCallback, wrappedErrback, wrappedProgressback)); - }, function(reason) { - if (done) return; - done = true; - result.resolve(wrappedErrback(reason)); - }, function(progress) { - if (done) return; - result.notify(wrappedProgressback(progress)); - }); - }); - - return result.promise; + var when = function(value, callback, errback, progressBack) { + var result = new Deferred(); + result.resolve(value); + return result.promise.then(callback, errback, progressBack); }; - - function defaultCallback(value) { - return value; - } - - - function defaultErrback(reason) { - return reject(reason); - } - - /** * @ngdoc method * @name $q#all @@ -11736,14 +12272,15 @@ function qFactory(nextTick, exceptionHandler) { * If any of the promises is resolved with a rejection, this resulting promise will be rejected * with the same rejection value. */ + function all(promises) { - var deferred = defer(), + var deferred = new Deferred(), counter = 0, results = isArray(promises) ? [] : {}; forEach(promises, function(promise, key) { counter++; - ref(promise).then(function(value) { + when(promise).then(function(value) { if (results.hasOwnProperty(key)) return; results[key] = value; if (!(--counter)) deferred.resolve(results); @@ -11760,12 +12297,37 @@ function qFactory(nextTick, exceptionHandler) { return deferred.promise; } - return { - defer: defer, - reject: reject, - when: when, - all: all + var $Q = function Q(resolver) { + if (!isFunction(resolver)) { + throw $qMinErr('norslvr', "Expected resolverFn, got '{0}'", resolver); + } + + if (!(this instanceof Q)) { + // More useful when $Q is the Promise itself. + return new Q(resolver); + } + + var deferred = new Deferred(); + + function resolveFn(value) { + deferred.resolve(value); + } + + function rejectFn(reason) { + deferred.reject(reason); + } + + resolver(resolveFn, rejectFn); + + return deferred.promise; }; + + $Q.defer = defer; + $Q.reject = reject; + $Q.when = when; + $Q.all = all; + + return $Q; } function $$RAFProvider(){ //rAF @@ -11871,6 +12433,7 @@ function $RootScopeProvider(){ var TTL = 10; var $rootScopeMinErr = minErr('$rootScope'); var lastDirtyWatch = null; + var applyAsyncId = null; this.digestTtl = function(value) { if (arguments.length) { @@ -11934,15 +12497,32 @@ function $RootScopeProvider(){ this.$$listeners = {}; this.$$listenerCount = {}; this.$$isolateBindings = {}; + this.$$applyAsyncQueue = []; } /** * @ngdoc property * @name $rootScope.Scope#$id - * @returns {number} Unique scope ID (monotonically increasing alphanumeric sequence) useful for - * debugging. + * + * @description + * Unique scope ID (monotonically increasing) useful for debugging. */ + /** + * @ngdoc property + * @name $rootScope.Scope#$parent + * + * @description + * Reference to the parent scope. + */ + + /** + * @ngdoc property + * @name $rootScope.Scope#$root + * + * @description + * Reference to the root scope. + */ Scope.prototype = { constructor: Scope, @@ -11954,9 +12534,8 @@ function $RootScopeProvider(){ * @description * Creates a new child {@link ng.$rootScope.Scope scope}. * - * The parent scope will propagate the {@link ng.$rootScope.Scope#$digest $digest()} and - * {@link ng.$rootScope.Scope#$digest $digest()} events. The scope can be removed from the - * scope hierarchy using {@link ng.$rootScope.Scope#$destroy $destroy()}. + * The parent scope will propagate the {@link ng.$rootScope.Scope#$digest $digest()} event. + * The scope can be removed from the scope hierarchy using {@link ng.$rootScope.Scope#$destroy $destroy()}. * * {@link ng.$rootScope.Scope#$destroy $destroy()} must be called on a scope when it is * desired for the scope and its child scopes to be permanently detached from the parent and @@ -11971,8 +12550,7 @@ function $RootScopeProvider(){ * */ $new: function(isolate) { - var ChildScope, - child; + var child; if (isolate) { child = new Scope(); @@ -11983,18 +12561,18 @@ function $RootScopeProvider(){ } else { // Only create a child scope class if somebody asks for one, // but cache it to allow the VM to optimize lookups. - if (!this.$$childScopeClass) { - this.$$childScopeClass = function() { + if (!this.$$ChildScope) { + this.$$ChildScope = function ChildScope() { this.$$watchers = this.$$nextSibling = this.$$childHead = this.$$childTail = null; this.$$listeners = {}; this.$$listenerCount = {}; this.$id = nextUid(); - this.$$childScopeClass = null; + this.$$ChildScope = null; }; - this.$$childScopeClass.prototype = this; + this.$$ChildScope.prototype = this; } - child = new this.$$childScopeClass(); + child = new this.$$ChildScope(); } child['this'] = child; child.$parent = this; @@ -12048,7 +12626,6 @@ function $RootScopeProvider(){ * can compare the `newVal` and `oldVal`. If these two values are identical (`===`) then the * listener was called due to initialization. * - * The example below contains an illustration of using a function as your $watch listener * * * # Example @@ -12078,14 +12655,14 @@ function $RootScopeProvider(){ - // Using a listener function + // Using a function as a watchExpression var food; scope.foodCounter = 0; expect(scope.foodCounter).toEqual(0); scope.$watch( - // This is the listener function + // This function returns the value being watched. It is called for each turn of the $digest loop function() { return food; }, - // This is the change handler + // This is the change listener, called when the value returned from the above function changes function(newValue, oldValue) { if ( newValue !== oldValue ) { // Only increment the counter if the value changed @@ -12115,20 +12692,23 @@ function $RootScopeProvider(){ * * - `string`: Evaluated as {@link guide/expression expression} * - `function(scope)`: called with current `scope` as a parameter. - * @param {(function()|string)=} listener Callback called whenever the return value of - * the `watchExpression` changes. - * - * - `string`: Evaluated as {@link guide/expression expression} - * - `function(newValue, oldValue, scope)`: called with current and previous values as - * parameters. + * @param {function(newVal, oldVal, scope)} listener Callback called whenever the value + * of `watchExpression` changes. * + * - `newVal` contains the current value of the `watchExpression` + * - `oldVal` contains the previous value of the `watchExpression` + * - `scope` refers to the current scope * @param {boolean=} objectEquality Compare for object equality using {@link angular.equals} instead of * comparing for reference equality. * @returns {function()} Returns a deregistration function for this listener. */ $watch: function(watchExp, listener, objectEquality) { + var get = $parse(watchExp); + + if (get.$$watchDelegate) { + return get.$$watchDelegate(this, listener, objectEquality, get); + } var scope = this, - get = compileToFn(watchExp, 'watch'), array = scope.$$watchers, watcher = { fn: listener, @@ -12140,18 +12720,8 @@ function $RootScopeProvider(){ lastDirtyWatch = null; - // in the case user pass string, we need to compile it, do we really need this ? if (!isFunction(listener)) { - var listenFn = compileToFn(listener || noop, 'listener'); - watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);}; - } - - if (typeof watchExp == 'string' && get.constant) { - var originalFn = watcher.fn; - watcher.fn = function(newVal, oldVal, scope) { - originalFn.call(this, newVal, oldVal, scope); - arrayRemove(array, watcher); - }; + watcher.fn = noop; } if (!array) { @@ -12167,6 +12737,89 @@ function $RootScopeProvider(){ }; }, + /** + * @ngdoc method + * @name $rootScope.Scope#$watchGroup + * @kind function + * + * @description + * A variant of {@link ng.$rootScope.Scope#$watch $watch()} where it watches an array of `watchExpressions`. + * If any one expression in the collection changes the `listener` is executed. + * + * - The items in the `watchExpressions` array are observed via standard $watch operation and are examined on every + * call to $digest() to see if any items changes. + * - The `listener` is called whenever any expression in the `watchExpressions` array changes. + * + * @param {Array.} watchExpressions Array of expressions that will be individually + * watched using {@link ng.$rootScope.Scope#$watch $watch()} + * + * @param {function(newValues, oldValues, scope)} listener Callback called whenever the return value of any + * expression in `watchExpressions` changes + * The `newValues` array contains the current values of the `watchExpressions`, with the indexes matching + * those of `watchExpression` + * and the `oldValues` array contains the previous values of the `watchExpressions`, with the indexes matching + * those of `watchExpression` + * The `scope` refers to the current scope. + * @returns {function()} Returns a de-registration function for all listeners. + */ + $watchGroup: function(watchExpressions, listener) { + var oldValues = new Array(watchExpressions.length); + var newValues = new Array(watchExpressions.length); + var deregisterFns = []; + var self = this; + var changeReactionScheduled = false; + var firstRun = true; + + if (!watchExpressions.length) { + // No expressions means we call the listener ASAP + var shouldCall = true; + self.$evalAsync(function () { + if (shouldCall) listener(newValues, newValues, self); + }); + return function deregisterWatchGroup() { + shouldCall = false; + }; + } + + if (watchExpressions.length === 1) { + // Special case size of one + return this.$watch(watchExpressions[0], function watchGroupAction(value, oldValue, scope) { + newValues[0] = value; + oldValues[0] = oldValue; + listener(newValues, (value === oldValue) ? newValues : oldValues, scope); + }); + } + + forEach(watchExpressions, function (expr, i) { + var unwatchFn = self.$watch(expr, function watchGroupSubAction(value, oldValue) { + newValues[i] = value; + oldValues[i] = oldValue; + if (!changeReactionScheduled) { + changeReactionScheduled = true; + self.$evalAsync(watchGroupAction); + } + }); + deregisterFns.push(unwatchFn); + }); + + function watchGroupAction() { + changeReactionScheduled = false; + + if (firstRun) { + firstRun = false; + listener(newValues, newValues, self); + } else { + listener(newValues, oldValues, self); + } + } + + return function deregisterWatchGroup() { + while (deregisterFns.length) { + deregisterFns.shift()(); + } + }; + }, + /** * @ngdoc method @@ -12235,15 +12888,15 @@ function $RootScopeProvider(){ // only track veryOldValue if the listener is asking for it var trackVeryOldValue = (listener.length > 1); var changeDetected = 0; - var objGetter = $parse(obj); + var changeDetector = $parse(obj, $watchCollectionInterceptor); var internalArray = []; var internalObject = {}; var initRun = true; var oldLength = 0; - function $watchCollectionWatch() { - newValue = objGetter(self); - var newLength, key; + function $watchCollectionInterceptor(_value) { + newValue = _value; + var newLength, key, bothNaN, newItem, oldItem; if (!isObject(newValue)) { // if primitive if (oldValue !== newValue) { @@ -12267,11 +12920,13 @@ function $RootScopeProvider(){ } // copy the items to oldValue and look for changes. for (var i = 0; i < newLength; i++) { - var bothNaN = (oldValue[i] !== oldValue[i]) && - (newValue[i] !== newValue[i]); - if (!bothNaN && (oldValue[i] !== newValue[i])) { + oldItem = oldValue[i]; + newItem = newValue[i]; + + bothNaN = (oldItem !== oldItem) && (newItem !== newItem); + if (!bothNaN && (oldItem !== newItem)) { changeDetected++; - oldValue[i] = newValue[i]; + oldValue[i] = newItem; } } } else { @@ -12286,14 +12941,18 @@ function $RootScopeProvider(){ for (key in newValue) { if (newValue.hasOwnProperty(key)) { newLength++; - if (oldValue.hasOwnProperty(key)) { - if (oldValue[key] !== newValue[key]) { + newItem = newValue[key]; + oldItem = oldValue[key]; + + if (key in oldValue) { + bothNaN = (oldItem !== oldItem) && (newItem !== newItem); + if (!bothNaN && (oldItem !== newItem)) { changeDetected++; - oldValue[key] = newValue[key]; + oldValue[key] = newItem; } } else { oldLength++; - oldValue[key] = newValue[key]; + oldValue[key] = newItem; changeDetected++; } } @@ -12302,7 +12961,7 @@ function $RootScopeProvider(){ // we used to have more keys, need to find them and destroy them. changeDetected++; for(key in oldValue) { - if (oldValue.hasOwnProperty(key) && !newValue.hasOwnProperty(key)) { + if (!newValue.hasOwnProperty(key)) { oldLength--; delete oldValue[key]; } @@ -12341,7 +13000,7 @@ function $RootScopeProvider(){ } } - return this.$watch($watchCollectionWatch, $watchCollectionAction); + return this.$watch(changeDetector, $watchCollectionAction); }, /** @@ -12361,7 +13020,7 @@ function $RootScopeProvider(){ * {@link ng.directive:ngController controllers} or in * {@link ng.$compileProvider#directive directives}. * Instead, you should call {@link ng.$rootScope.Scope#$apply $apply()} (typically from within - * a {@link ng.$compileProvider#directive directives}), which will force a `$digest()`. + * a {@link ng.$compileProvider#directive directive}), which will force a `$digest()`. * * If you want to be notified whenever `$digest()` is called, * you can register a `watchExpression` function with @@ -12407,6 +13066,15 @@ function $RootScopeProvider(){ logIdx, logMsg, asyncTask; beginPhase('$digest'); + // Check for changes to browser url that happened in sync before the call to $digest + $browser.$$checkUrlChange(); + + if (this === $rootScope && applyAsyncId !== null) { + // If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then + // cancel the scheduled $apply and flush the queue of expressions to be evaluated. + $browser.defer.cancel(applyAsyncId); + flushApplyAsync(); + } lastDirtyWatch = null; @@ -12419,7 +13087,6 @@ function $RootScopeProvider(){ asyncTask = asyncQueue.shift(); asyncTask.scope.$eval(asyncTask.expression); } catch (e) { - clearPhase(); $exceptionHandler(e); } lastDirtyWatch = null; @@ -12462,7 +13129,6 @@ function $RootScopeProvider(){ } } } catch (e) { - clearPhase(); $exceptionHandler(e); } } @@ -12546,7 +13212,9 @@ function $RootScopeProvider(){ this.$$destroyed = true; if (this === $rootScope) return; - forEach(this.$$listenerCount, bind(null, decrementListenerCount, this)); + for (var eventName in this.$$listenerCount) { + decrementListenerCount(this, this.$$listenerCount[eventName], eventName); + } // sever all the references to parent scopes (after this cleanup, the current scope should // not be retained by any of our references and should be eligible for garbage collection) @@ -12573,7 +13241,7 @@ function $RootScopeProvider(){ // prevent NPEs since these methods have references to properties we nulled out this.$destroy = this.$digest = this.$apply = noop; - this.$on = this.$watch = function() { return noop; }; + this.$on = this.$watch = this.$watchGroup = function() { return noop; }; }, /** @@ -12717,6 +13385,33 @@ function $RootScopeProvider(){ } }, + /** + * @ngdoc method + * @name $rootScope.Scope#$applyAsync + * @kind function + * + * @description + * Schedule the invokation of $apply to occur at a later time. The actual time difference + * varies across browsers, but is typically around ~10 milliseconds. + * + * This can be used to queue up multiple expressions which need to be evaluated in the same + * digest. + * + * @param {(string|function())=} exp An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with current `scope` parameter. + */ + $applyAsync: function(expr) { + var scope = this; + expr && $rootScope.$$applyAsyncQueue.push($applyAsyncExpression); + scheduleApplyAsync(); + + function $applyAsyncExpression() { + scope.$eval(expr); + } + }, + /** * @ngdoc method * @name $rootScope.Scope#$on @@ -12731,7 +13426,8 @@ function $RootScopeProvider(){ * * - `targetScope` - `{Scope}`: the scope on which the event was `$emit`-ed or * `$broadcast`-ed. - * - `currentScope` - `{Scope}`: the current scope which is handling the event. + * - `currentScope` - `{Scope}`: the scope that is currently handling the event. Once the + * event propagates through the scope hierarchy, this property is set to null. * - `name` - `{string}`: name of the event. * - `stopPropagation` - `{function=}`: calling `stopPropagation` function will cancel * further event propagation (available only for events that were `$emit`-ed). @@ -12760,7 +13456,7 @@ function $RootScopeProvider(){ var self = this; return function() { - namedListeners[indexOf(namedListeners, listener)] = null; + namedListeners[namedListeners.indexOf(listener)] = null; decrementListenerCount(self, 1, name); }; }, @@ -12825,11 +13521,16 @@ function $RootScopeProvider(){ } } //if any listener on the current scope stops propagation, prevent bubbling - if (stopPropagation) return event; + if (stopPropagation) { + event.currentScope = null; + return event; + } //traverse upwards scope = scope.$parent; } while (scope); + event.currentScope = null; + return event; }, @@ -12866,8 +13567,11 @@ function $RootScopeProvider(){ event.defaultPrevented = true; }, defaultPrevented: false - }, - listenerArgs = concat([event], arguments, 1), + }; + + if (!target.$$listenerCount[name]) return event; + + var listenerArgs = concat([event], arguments, 1), listeners, i, length; //down while you can, then up and next sibling or up and next sibling until back at root @@ -12902,6 +13606,7 @@ function $RootScopeProvider(){ } } + event.currentScope = null; return event; } }; @@ -12923,11 +13628,6 @@ function $RootScopeProvider(){ $rootScope.$$phase = null; } - function compileToFn(exp, name) { - var fn = $parse(exp); - assertArgFn(fn, name); - return fn; - } function decrementListenerCount(current, count, name) { do { @@ -12944,6 +13644,26 @@ function $RootScopeProvider(){ * because it's unique we can easily tell it apart from other values */ function initWatchVal() {} + + function flushApplyAsync() { + var queue = $rootScope.$$applyAsyncQueue; + while (queue.length) { + try { + queue.shift()(); + } catch(e) { + $exceptionHandler(e); + } + } + applyAsyncId = null; + } + + function scheduleApplyAsync() { + if (applyAsyncId === null) { + applyAsyncId = $browser.defer(function() { + $rootScope.$apply(flushApplyAsync); + }); + } + } }]; } @@ -12953,7 +13673,7 @@ function $RootScopeProvider(){ */ function $$SanitizeUriProvider() { var aHrefSanitizationWhitelist = /^\s*(https?|ftp|mailto|tel|file):/, - imgSrcSanitizationWhitelist = /^\s*(https?|ftp|file):|data:image\//; + imgSrcSanitizationWhitelist = /^\s*((https?|ftp|file|blob):|data:image\/)/; /** * @description @@ -13514,7 +14234,7 @@ function $SceDelegateProvider() { * won't work on all browsers. Also, loading templates from `file://` URL does not work on some * browsers. * - * ## This feels like too much overhead for the developer? + * ## This feels like too much overhead * * It's important to remember that SCE only applies to interpolation expressions. * @@ -13598,7 +14318,7 @@ function $SceDelegateProvider() { * * * - *
+ *
*

* User comments
* By default, HTML that isn't explicitly trusted (e.g. Alice's comment) is sanitized when @@ -13615,17 +14335,17 @@ function $SceDelegateProvider() { * * * - * var mySceApp = angular.module('mySceApp', ['ngSanitize']); - * - * mySceApp.controller("myAppController", function myAppController($http, $templateCache, $sce) { - * var self = this; - * $http.get("test_data.json", {cache: $templateCache}).success(function(userComments) { - * self.userComments = userComments; - * }); - * self.explicitlyTrustedHtml = $sce.trustAsHtml( - * 'Hover over this text.'); - * }); + * angular.module('mySceApp', ['ngSanitize']) + * .controller('AppController', ['$http', '$templateCache', '$sce', + * function($http, $templateCache, $sce) { + * var self = this; + * $http.get("test_data.json", {cache: $templateCache}).success(function(userComments) { + * self.userComments = userComments; + * }); + * self.explicitlyTrustedHtml = $sce.trustAsHtml( + * 'Hover over this text.'); + * }]); * * * @@ -13807,9 +14527,9 @@ function $SceProvider() { if (parsed.literal && parsed.constant) { return parsed; } else { - return function sceParseAsTrusted(self, locals) { - return sce.getTrusted(type, parsed(self, locals)); - }; + return $parse(expr, function (value) { + return sce.getTrusted(type, value); + }); } }; @@ -14169,9 +14889,176 @@ function $SnifferProvider() { }]; } +var $compileMinErr = minErr('$compile'); + +/** + * @ngdoc service + * @name $templateRequest + * + * @description + * The `$templateRequest` service downloads the provided template using `$http` and, upon success, + * stores the contents inside of `$templateCache`. If the HTTP request fails or the response data + * of the HTTP request is empty then a `$compile` error will be thrown (the exception can be thwarted + * by setting the 2nd parameter of the function to true). + * + * @param {string} tpl The HTTP request template URL + * @param {boolean=} ignoreRequestError Whether or not to ignore the exception when the request fails or the template is empty + * + * @return {Promise} the HTTP Promise for the given. + * + * @property {number} totalPendingRequests total amount of pending template requests being downloaded. + */ +function $TemplateRequestProvider() { + this.$get = ['$templateCache', '$http', '$q', function($templateCache, $http, $q) { + function handleRequestFn(tpl, ignoreRequestError) { + var self = handleRequestFn; + self.totalPendingRequests++; + + return $http.get(tpl, { cache : $templateCache }) + .then(function(response) { + var html = response.data; + if(!html || html.length === 0) { + return handleError(); + } + + self.totalPendingRequests--; + $templateCache.put(tpl, html); + return html; + }, handleError); + + function handleError() { + self.totalPendingRequests--; + if (!ignoreRequestError) { + throw $compileMinErr('tpload', 'Failed to load template: {0}', tpl); + } + return $q.reject(); + } + } + + handleRequestFn.totalPendingRequests = 0; + + return handleRequestFn; + }]; +} + +function $$TestabilityProvider() { + this.$get = ['$rootScope', '$browser', '$location', + function($rootScope, $browser, $location) { + + /** + * @name $testability + * + * @description + * The private $$testability service provides a collection of methods for use when debugging + * or by automated test and debugging tools. + */ + var testability = {}; + + /** + * @name $$testability#findBindings + * + * @description + * Returns an array of elements that are bound (via ng-bind or {{}}) + * to expressions matching the input. + * + * @param {Element} element The element root to search from. + * @param {string} expression The binding expression to match. + * @param {boolean} opt_exactMatch If true, only returns exact matches + * for the expression. Filters and whitespace are ignored. + */ + testability.findBindings = function(element, expression, opt_exactMatch) { + var bindings = element.getElementsByClassName('ng-binding'); + var matches = []; + forEach(bindings, function(binding) { + var dataBinding = angular.element(binding).data('$binding'); + if (dataBinding) { + forEach(dataBinding, function(bindingName) { + if (opt_exactMatch) { + var matcher = new RegExp('(^|\\s)' + expression + '(\\s|\\||$)'); + if (matcher.test(bindingName)) { + matches.push(binding); + } + } else { + if (bindingName.indexOf(expression) != -1) { + matches.push(binding); + } + } + }); + } + }); + return matches; + }; + + /** + * @name $$testability#findModels + * + * @description + * Returns an array of elements that are two-way found via ng-model to + * expressions matching the input. + * + * @param {Element} element The element root to search from. + * @param {string} expression The model expression to match. + * @param {boolean} opt_exactMatch If true, only returns exact matches + * for the expression. + */ + testability.findModels = function(element, expression, opt_exactMatch) { + var prefixes = ['ng-', 'data-ng-', 'ng\\:']; + for (var p = 0; p < prefixes.length; ++p) { + var attributeEquals = opt_exactMatch ? '=' : '*='; + var selector = '[' + prefixes[p] + 'model' + attributeEquals + '"' + expression + '"]'; + var elements = element.querySelectorAll(selector); + if (elements.length) { + return elements; + } + } + }; + + /** + * @name $$testability#getLocation + * + * @description + * Shortcut for getting the location in a browser agnostic way. Returns + * the path, search, and hash. (e.g. /path?a=b#hash) + */ + testability.getLocation = function() { + return $location.url(); + }; + + /** + * @name $$testability#setLocation + * + * @description + * Shortcut for navigating to a location without doing a full page reload. + * + * @param {string} url The location url (path, search and hash, + * e.g. /path?a=b#hash) to go to. + */ + testability.setLocation = function(url) { + if (url !== $location.url()) { + $location.url(url); + $rootScope.$digest(); + } + }; + + /** + * @name $$testability#whenStable + * + * @description + * Calls the callback when $timeout and $http requests are completed. + * + * @param {function} callback + */ + testability.whenStable = function(callback) { + $browser.notifyWhenNoOutstandingRequests(callback); + }; + + return testability; + }]; +} + function $TimeoutProvider() { - this.$get = ['$rootScope', '$browser', '$q', '$exceptionHandler', - function($rootScope, $browser, $q, $exceptionHandler) { + this.$get = ['$rootScope', '$browser', '$q', '$$q', '$exceptionHandler', + function($rootScope, $browser, $q, $$q, $exceptionHandler) { var deferreds = {}; @@ -14201,9 +15088,9 @@ function $TimeoutProvider() { * */ function timeout(fn, delay, invokeApply) { - var deferred = $q.defer(), + var skipApply = (isDefined(invokeApply) && !invokeApply), + deferred = (skipApply ? $$q : $q).defer(), promise = deferred.promise, - skipApply = (isDefined(invokeApply) && !invokeApply), timeoutId; timeoutId = $browser.defer(function() { @@ -14400,6 +15287,17 @@ function $WindowProvider(){ this.$get = valueFn(window); } +/* global currencyFilter: true, + dateFilter: true, + filterFilter: true, + jsonFilter: true, + limitToFilter: true, + lowercaseFilter: true, + numberFilter: true, + orderByFilter: true, + uppercaseFilter: true, + */ + /** * @ngdoc provider * @name $filterProvider @@ -14449,16 +15347,6 @@ function $WindowProvider(){ * For more information about how angular filters work, and how to create your own filters, see * {@link guide/filter Filters} in the Angular Developer Guide. */ -/** - * @ngdoc method - * @name $filterProvider#register - * @description - * Register filter factory function. - * - * @param {String} name Name of the filter. - * @param {Function} fn The filter factory function which is injectable. - */ - /** * @ngdoc service @@ -14497,7 +15385,7 @@ function $FilterProvider($provide) { /** * @ngdoc method - * @name $controllerProvider#register + * @name $filterProvider#register * @param {string|Object} name Name of the filter function, or an object map of filters where * the keys are the filter names and the values are the filter factories. * @returns {Object} Registered filter instance, or if a map of filters was provided then a map @@ -14570,11 +15458,13 @@ function $FilterProvider($provide) { * which have property `name` containing "M" and property `phone` containing "1". A special * property name `$` can be used (as in `{$:"text"}`) to accept a match against any * property of the object. That's equivalent to the simple substring match with a `string` - * as described above. + * as described above. The predicate can be negated by prefixing the string with `!`. + * For Example `{name: "!M"}` predicate will return an array of items which have property `name` + * not containing "M". * - * - `function(value)`: A predicate function can be used to write arbitrary filters. The function is - * called for each element of `array`. The final result is an array of those elements that - * the predicate returned true for. + * - `function(value, index)`: A predicate function can be used to write arbitrary filters. The + * function is called for each element of `array`. The final result is an array of those + * elements that the predicate returned true for. * * @param {function(actual, expected)|true|undefined} comparator Comparator which is used in * determining if the expected value (from the filter expression) and actual value (from @@ -14667,9 +15557,9 @@ function filterFilter() { var comparatorType = typeof(comparator), predicates = []; - predicates.check = function(value) { + predicates.check = function(value, index) { for (var j = 0; j < predicates.length; j++) { - if(!predicates[j](value)) { + if(!predicates[j](value, index)) { return false; } } @@ -14758,7 +15648,7 @@ function filterFilter() { var filtered = []; for ( var j = 0; j < array.length; j++) { var value = array[j]; - if (predicates.check(value)) { + if (predicates.check(value, j)) { filtered.push(value); } } @@ -14819,8 +15709,12 @@ function currencyFilter($locale) { var formats = $locale.NUMBER_FORMATS; return function(amount, currencySymbol){ if (isUndefined(currencySymbol)) currencySymbol = formats.CURRENCY_SYM; - return formatNumber(amount, formats.PATTERNS[1], formats.GROUP_SEP, formats.DECIMAL_SEP, 2). - replace(/\u00A4/g, currencySymbol); + + // if null or undefined pass it through + return (amount == null) + ? amount + : formatNumber(amount, formats.PATTERNS[1], formats.GROUP_SEP, formats.DECIMAL_SEP, 2). + replace(/\u00A4/g, currencySymbol); }; } @@ -14879,14 +15773,18 @@ numberFilter.$inject = ['$locale']; function numberFilter($locale) { var formats = $locale.NUMBER_FORMATS; return function(number, fractionSize) { - return formatNumber(number, formats.PATTERNS[0], formats.GROUP_SEP, formats.DECIMAL_SEP, - fractionSize); + + // if null or undefined pass it through + return (number == null) + ? number + : formatNumber(number, formats.PATTERNS[0], formats.GROUP_SEP, formats.DECIMAL_SEP, + fractionSize); }; } var DECIMAL_SEP = '.'; function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { - if (number == null || !isFinite(number) || isObject(number)) return ''; + if (!isFinite(number) || isObject(number)) return ''; var isNegative = number < 0; number = Math.abs(number); @@ -14919,6 +15817,10 @@ function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round number = +(Math.round(+(number.toString() + 'e' + fractionSize)).toString() + 'e' + -fractionSize); + if (number === 0) { + isNegative = false; + } + var fraction = ('' + number).split(DECIMAL_SEP); var whole = fraction[0]; fraction = fraction[1] || ''; @@ -15007,6 +15909,32 @@ function timeZoneGetter(date) { return paddedZone; } +function getFirstThursdayOfYear(year) { + // 0 = index of January + var dayOfWeekOnFirst = (new Date(year, 0, 1)).getDay(); + // 4 = index of Thursday (+1 to account for 1st = 5) + // 11 = index of *next* Thursday (+1 account for 1st = 12) + return new Date(year, 0, ((dayOfWeekOnFirst <= 4) ? 5 : 12) - dayOfWeekOnFirst); +} + +function getThursdayThisWeek(datetime) { + return new Date(datetime.getFullYear(), datetime.getMonth(), + // 4 = index of Thursday + datetime.getDate() + (4 - datetime.getDay())); +} + +function weekGetter(size) { + return function(date) { + var firstThurs = getFirstThursdayOfYear(date.getFullYear()), + thisThurs = getThursdayThisWeek(date); + + var diff = +thisThurs - +firstThurs, + result = 1 + Math.round(diff / 6.048e8); // 6.048e8 ms per week + + return padNumber(result, size); + }; +} + function ampmGetter(date, formats) { return date.getHours() < 12 ? formats.AMPMS[0] : formats.AMPMS[1]; } @@ -15035,10 +15963,12 @@ var DATE_FORMATS = { EEEE: dateStrGetter('Day'), EEE: dateStrGetter('Day', true), a: ampmGetter, - Z: timeZoneGetter + Z: timeZoneGetter, + ww: weekGetter(2), + w: weekGetter(1) }; -var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/, +var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZEw']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z|w+))(.*)/, NUMBER_STRING = /^\-?\d+$/; /** @@ -15064,40 +15994,44 @@ var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+ * * `'EEE'`: Day in Week, (Sun-Sat) * * `'HH'`: Hour in day, padded (00-23) * * `'H'`: Hour in day (0-23) - * * `'hh'`: Hour in am/pm, padded (01-12) - * * `'h'`: Hour in am/pm, (1-12) + * * `'hh'`: Hour in AM/PM, padded (01-12) + * * `'h'`: Hour in AM/PM, (1-12) * * `'mm'`: Minute in hour, padded (00-59) * * `'m'`: Minute in hour (0-59) * * `'ss'`: Second in minute, padded (00-59) * * `'s'`: Second in minute (0-59) * * `'.sss' or ',sss'`: Millisecond in second, padded (000-999) - * * `'a'`: am/pm marker + * * `'a'`: AM/PM marker * * `'Z'`: 4 digit (+sign) representation of the timezone offset (-1200-+1200) + * * `'ww'`: ISO-8601 week of year (00-53) + * * `'w'`: ISO-8601 week of year (0-53) * * `format` string can also be one of the following predefined * {@link guide/i18n localizable formats}: * * * `'medium'`: equivalent to `'MMM d, y h:mm:ss a'` for en_US locale - * (e.g. Sep 3, 2010 12:05:08 pm) - * * `'short'`: equivalent to `'M/d/yy h:mm a'` for en_US locale (e.g. 9/3/10 12:05 pm) - * * `'fullDate'`: equivalent to `'EEEE, MMMM d,y'` for en_US locale + * (e.g. Sep 3, 2010 12:05:08 PM) + * * `'short'`: equivalent to `'M/d/yy h:mm a'` for en_US locale (e.g. 9/3/10 12:05 PM) + * * `'fullDate'`: equivalent to `'EEEE, MMMM d, y'` for en_US locale * (e.g. Friday, September 3, 2010) * * `'longDate'`: equivalent to `'MMMM d, y'` for en_US locale (e.g. September 3, 2010) * * `'mediumDate'`: equivalent to `'MMM d, y'` for en_US locale (e.g. Sep 3, 2010) * * `'shortDate'`: equivalent to `'M/d/yy'` for en_US locale (e.g. 9/3/10) - * * `'mediumTime'`: equivalent to `'h:mm:ss a'` for en_US locale (e.g. 12:05:08 pm) - * * `'shortTime'`: equivalent to `'h:mm a'` for en_US locale (e.g. 12:05 pm) + * * `'mediumTime'`: equivalent to `'h:mm:ss a'` for en_US locale (e.g. 12:05:08 PM) + * * `'shortTime'`: equivalent to `'h:mm a'` for en_US locale (e.g. 12:05 PM) * - * `format` string can contain literal values. These need to be quoted with single quotes (e.g. - * `"h 'in the morning'"`). In order to output single quote, use two single quotes in a sequence + * `format` string can contain literal values. These need to be escaped by surrounding with single quotes (e.g. + * `"h 'in the morning'"`). In order to output a single quote, escape it - i.e., two single quotes in a sequence * (e.g. `"h 'o''clock'"`). * * @param {(Date|number|string)} date Date to format either as Date object, milliseconds (string or - * number) or various ISO 8601 datetime string formats (e.g. yyyy-MM-ddTHH:mm:ss.SSSZ and its + * number) or various ISO 8601 datetime string formats (e.g. yyyy-MM-ddTHH:mm:ss.sssZ and its * shorter versions like yyyy-MM-ddTHH:mmZ, yyyy-MM-dd or yyyyMMddTHHmmssZ). If no timezone is * specified in the string input, the time is considered to be in the local timezone. * @param {string=} format Formatting rules (see Description). If not specified, * `mediumDate` is used. + * @param {string=} timezone Timezone to be used for formatting. Right now, only `'UTC'` is supported. + * If not specified, the timezone of the browser will be used. * @returns {string} Formatted string or the input if input is not recognized as date/millis. * * @example @@ -15109,6 +16043,8 @@ var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+ {{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}
{{1288323623006 | date:'MM/dd/yyyy @ h:mma'}}: {{'1288323623006' | date:'MM/dd/yyyy @ h:mma'}}
+ {{1288323623006 | date:"MM/dd/yyyy 'at' h:mma"}}: + {{'1288323623006' | date:"MM/dd/yyyy 'at' h:mma"}}
it('should format date', function() { @@ -15118,6 +16054,8 @@ var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+ toMatch(/2010\-10\-2\d \d{2}:\d{2}:\d{2} (\-|\+)?\d{4}/); expect(element(by.binding("'1288323623006' | date:'MM/dd/yyyy @ h:mma'")).getText()). toMatch(/10\/2\d\/2010 @ \d{1,2}:\d{2}(AM|PM)/); + expect(element(by.binding("'1288323623006' | date:\"MM/dd/yyyy 'at' h:mma\"")).getText()). + toMatch(/10\/2\d\/2010 at \d{1,2}:\d{2}(AM|PM)/); }); @@ -15153,7 +16091,7 @@ function dateFilter($locale) { } - return function(date, format) { + return function(date, format, timezone) { var text = '', parts = [], fn, match; @@ -15161,11 +16099,7 @@ function dateFilter($locale) { format = format || 'mediumDate'; format = $locale.DATETIME_FORMATS[format] || format; if (isString(date)) { - if (NUMBER_STRING.test(date)) { - date = int(date); - } else { - date = jsonStringToDate(date); - } + date = NUMBER_STRING.test(date) ? int(date) : jsonStringToDate(date); } if (isNumber(date)) { @@ -15187,6 +16121,10 @@ function dateFilter($locale) { } } + if (timezone && timezone === 'UTC') { + date = new Date(date.getTime()); + date.setMinutes(date.getMinutes() + date.getTimezoneOffset()); + } forEach(parts, function(value){ fn = DATE_FORMATS[value]; text += fn ? fn(date, $locale.DATETIME_FORMATS) @@ -15386,9 +16324,13 @@ function limitToFilter(){ * * - `function`: Getter function. The result of this function will be sorted using the * `<`, `=`, `>` operator. - * - `string`: An Angular expression which evaluates to an object to order by, such as 'name' - * to sort by a property called 'name'. Optionally prefixed with `+` or `-` to control - * ascending or descending sort order (for example, +name or -name). + * - `string`: An Angular expression. The result of this expression is used to compare elements + * (for example `name` to sort by a property called `name` or `name.substr(0, 3)` to sort by + * 3 first characters of a property called `name`). The result of a constant expression + * is interpreted as a property name to be used in comparisons (for example `"special name"` + * to sort object by the value of their `special name` property). An expression can be + * optionally prefixed with `+` or `-` to control ascending or descending sort order + * (for example, `+name` or `-name`). * - `Array`: An array of function or string predicates. The first predicate in the array * is used for sorting, but when two items are equivalent, the next predicate is used. * @@ -15440,7 +16382,7 @@ function limitToFilter(){ * @example -
+
-
Name @@ -15479,7 +16421,7 @@ function limitToFilter(){ orderByFilter.$inject = ['$parse']; function orderByFilter($parse){ return function(array, sortPredicate, reverseOrder) { - if (!isArray(array)) return array; + if (!(isArrayLike(array))) return array; if (!sortPredicate) return array; sortPredicate = isArray(sortPredicate) ? sortPredicate: [sortPredicate]; sortPredicate = map(sortPredicate, function(predicate){ @@ -15513,7 +16455,7 @@ function orderByFilter($parse){ return 0; } function reverseComparator(comp, descending) { - return toBoolean(descending) + return descending ? function(a,b){return comp(b,a);} : comp; } @@ -15521,6 +16463,10 @@ function orderByFilter($parse){ var t1 = typeof v1; var t2 = typeof v2; if (t1 == t2) { + if (isDate(v1) && isDate(v2)) { + v1 = v1.valueOf(); + v2 = v2.valueOf(); + } if (t1 == "string") { v1 = v1.toLowerCase(); v2 = v2.toLowerCase(); @@ -15609,12 +16555,12 @@ var htmlAnchorDirective = valueFn({ * * The wrong way to write it: * ```html - * + * link1 * ``` * * The correct way to write it: * ```html - * + * link1 * ``` * * @element A @@ -15752,7 +16698,7 @@ var htmlAnchorDirective = valueFn({ * * @description * - * The following markup will make the button enabled on Chrome/Firefox but not on IE8 and older IEs: + * We shouldn't do this, because it will make the button enabled on Chrome/Firefox but not on IE8 and older IEs: * ```html *
* @@ -15943,6 +16889,7 @@ forEach(BOOLEAN_ATTR, function(propName, attrName) { var normalized = directiveNormalize('ng-' + attrName); ngAttributeAliasDirectives[normalized] = function() { return { + restrict: 'A', priority: 100, link: function(scope, element, attr) { scope.$watch(attr[normalized], function ngBooleanAttrWatchAction(value) { @@ -15953,6 +16900,29 @@ forEach(BOOLEAN_ATTR, function(propName, attrName) { }; }); +// aliased input attrs are evaluated +forEach(ALIASED_ATTR, function(htmlAttr, ngAttr) { + ngAttributeAliasDirectives[ngAttr] = function() { + return { + priority: 100, + link: function(scope, element, attr) { + //special case ngPattern when a literal regular expression value + //is used as the expression (this way we don't have to watch anything). + if (ngAttr === "ngPattern" && attr.ngPattern.charAt(0) == "/") { + var match = attr.ngPattern.match(REGEX_STRING_REGEXP); + if (match) { + attr.$set("ngPattern", new RegExp(match[1], match[2])); + return; + } + } + + scope.$watch(attr[ngAttr], function ngAttrAliasWatchAction(value) { + attr.$set(ngAttr, value); + }); + } + }; + }; +}); // ng-src, ng-srcset, ng-href are interpolated forEach(['src', 'srcset', 'href'], function(attrName) { @@ -15972,8 +16942,12 @@ forEach(['src', 'srcset', 'href'], function(attrName) { } attr.$observe(normalized, function(value) { - if (!value) - return; + if (!value) { + if (attrName === 'href') { + attr.$set(name, null); + } + return; + } attr.$set(name, value); @@ -15988,14 +16962,19 @@ forEach(['src', 'srcset', 'href'], function(attrName) { }; }); -/* global -nullFormCtrl */ +/* global -nullFormCtrl, -SUBMITTED_CLASS, addSetValidityMethod: true + */ var nullFormCtrl = { $addControl: noop, $removeControl: noop, $setValidity: noop, + $$setPending: noop, $setDirty: noop, - $setPristine: noop -}; + $setPristine: noop, + $setSubmitted: noop, + $$clearControlValidity: noop +}, +SUBMITTED_CLASS = 'ng-submitted'; /** * @ngdoc type @@ -16005,13 +16984,13 @@ var nullFormCtrl = { * @property {boolean} $dirty True if user has already interacted with the form. * @property {boolean} $valid True if all of the containing forms and controls are valid. * @property {boolean} $invalid True if at least one containing control or form is invalid. + * @property {boolean} $submitted True if user has submitted the form even if its invalid. * - * @property {Object} $error Is an object hash, containing references to all invalid controls or - * forms, where: + * @property {Object} $error Is an object hash, containing references to controls or + * forms with failing validators, where: * * - keys are validation tokens (error names), - * - values are arrays of controls or forms that are invalid for given error name. - * + * - values are arrays of controls or forms that have a failing validator for given error name. * * Built-in validation tokens: * @@ -16038,29 +17017,57 @@ FormController.$inject = ['$element', '$attrs', '$scope', '$animate']; function FormController(element, attrs, $scope, $animate) { var form = this, parentForm = element.parent().controller('form') || nullFormCtrl, - invalidCount = 0, // used to easily determine if we are valid - errors = form.$error = {}, controls = []; // init state + form.$error = {}; + form.$$success = {}; + form.$pending = undefined; form.$name = attrs.name || attrs.ngForm; form.$dirty = false; form.$pristine = true; form.$valid = true; form.$invalid = false; + form.$submitted = false; parentForm.$addControl(form); // Setup initial state of the control element.addClass(PRISTINE_CLASS); - toggleValidCss(true); - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $animate.removeClass(element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); - $animate.addClass(element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); - } + /** + * @ngdoc method + * @name form.FormController#$rollbackViewValue + * + * @description + * Rollback all form controls pending updates to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. This method is typically needed by the reset button of + * a form that uses `ng-model-options` to pend updates. + */ + form.$rollbackViewValue = function() { + forEach(controls, function(control) { + control.$rollbackViewValue(); + }); + }; + + /** + * @ngdoc method + * @name form.FormController#$commitViewValue + * + * @description + * Commit all form controls pending updates to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. This method is rarely needed as `NgModelController` + * usually handles calling this in response to input events. + */ + form.$commitViewValue = function() { + forEach(controls, function(control) { + control.$commitViewValue(); + }); + }; /** * @ngdoc method @@ -16095,13 +17102,17 @@ function FormController(element, attrs, $scope, $animate) { if (control.$name && form[control.$name] === control) { delete form[control.$name]; } - forEach(errors, function(queue, validationToken) { - form.$setValidity(validationToken, true, control); + forEach(form.$pending, function(value, name) { + form.$setValidity(name, null, control); + }); + forEach(form.$error, function(value, name) { + form.$setValidity(name, null, control); }); arrayRemove(controls, control); }; + /** * @ngdoc method * @name form.FormController#$setValidity @@ -16111,43 +17122,33 @@ function FormController(element, attrs, $scope, $animate) { * * This method will also propagate to parent forms. */ - form.$setValidity = function(validationToken, isValid, control) { - var queue = errors[validationToken]; - - if (isValid) { - if (queue) { - arrayRemove(queue, control); - if (!queue.length) { - invalidCount--; - if (!invalidCount) { - toggleValidCss(isValid); - form.$valid = true; - form.$invalid = false; - } - errors[validationToken] = false; - toggleValidCss(true, validationToken); - parentForm.$setValidity(validationToken, true, form); + addSetValidityMethod({ + ctrl: this, + $element: element, + set: function(object, property, control) { + var list = object[property]; + if (!list) { + object[property] = [control]; + } else { + var index = list.indexOf(control); + if (index === -1) { + list.push(control); } } - - } else { - if (!invalidCount) { - toggleValidCss(isValid); + }, + unset: function(object, property, control) { + var list = object[property]; + if (!list) { + return; } - if (queue) { - if (includes(queue, control)) return; - } else { - errors[validationToken] = queue = []; - invalidCount++; - toggleValidCss(false, validationToken); - parentForm.$setValidity(validationToken, false, form); + arrayRemove(list, control); + if (list.length === 0) { + delete object[property]; } - queue.push(control); - - form.$valid = false; - form.$invalid = true; - } - }; + }, + parentForm: parentForm, + $animate: $animate + }); /** * @ngdoc method @@ -16182,16 +17183,28 @@ function FormController(element, attrs, $scope, $animate) { * saving or resetting it. */ form.$setPristine = function () { - $animate.removeClass(element, DIRTY_CLASS); - $animate.addClass(element, PRISTINE_CLASS); + $animate.setClass(element, PRISTINE_CLASS, DIRTY_CLASS + ' ' + SUBMITTED_CLASS); form.$dirty = false; form.$pristine = true; + form.$submitted = false; forEach(controls, function(control) { control.$setPristine(); }); }; -} + /** + * @ngdoc method + * @name form.FormController#$setSubmitted + * + * @description + * Sets the form to its submitted state. + */ + form.$setSubmitted = function () { + $animate.addClass(element, SUBMITTED_CLASS); + form.$submitted = true; + parentForm.$setSubmitted(); + }; +} /** * @ngdoc directive @@ -16241,6 +17254,7 @@ function FormController(element, attrs, $scope, $animate) { * - `ng-invalid` is set if the form is invalid. * - `ng-pristine` is set if the form is pristine. * - `ng-dirty` is set if the form is dirty. + * - `ng-submitted` is set if the form was submitted. * * Keep in mind that ngAnimate can detect each of these classes when added and removed. * @@ -16274,8 +17288,9 @@ function FormController(element, attrs, $scope, $animate) { * hitting enter in any of the input fields will trigger the click handler on the *first* button or * input[type=submit] (`ngClick`) *and* a submit handler on the enclosing form (`ngSubmit`) * - * @param {string=} name Name of the form. If specified, the form controller will be published into - * related scope, under this name. + * Any pending `ngModelOptions` changes will take place immediately when an enclosing form is + * submitted. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` + * to have access to the updated model. * * ## Animation Hooks * @@ -16353,6 +17368,8 @@ function FormController(element, attrs, $scope, $animate) { * + * @param {string=} name Name of the form. If specified, the form controller will be published into + * related scope, under this name. */ var formDirectiveFactory = function(isNgForm) { return ['$timeout', function($timeout) { @@ -16370,19 +17387,24 @@ var formDirectiveFactory = function(isNgForm) { // IE 9 is not affected because it doesn't fire a submit event and try to do a full // page reload if the form was destroyed by submission of the form via a click handler // on a button in the form. Looks like an IE9 specific bug. - var preventDefaultListener = function(event) { + var handleFormSubmission = function(event) { + scope.$apply(function() { + controller.$commitViewValue(); + controller.$setSubmitted(); + }); + event.preventDefault ? event.preventDefault() : event.returnValue = false; // IE }; - addEventListenerFn(formElement[0], 'submit', preventDefaultListener); + addEventListenerFn(formElement[0], 'submit', handleFormSubmission); // unregister the preventDefault listener so that we don't not leak memory but in a // way that will achieve the prevention of the default action. formElement.on('$destroy', function() { $timeout(function() { - removeEventListenerFn(formElement[0], 'submit', preventDefaultListener); + removeEventListenerFn(formElement[0], 'submit', handleFormSubmission); }, 0, false); }); } @@ -16414,17 +17436,27 @@ var formDirectiveFactory = function(isNgForm) { var formDirective = formDirectiveFactory(); var ngFormDirective = formDirectiveFactory(true); -/* global - - -VALID_CLASS, - -INVALID_CLASS, - -PRISTINE_CLASS, - -DIRTY_CLASS +/* global VALID_CLASS: true, + INVALID_CLASS: true, + PRISTINE_CLASS: true, + DIRTY_CLASS: true, + UNTOUCHED_CLASS: true, + TOUCHED_CLASS: true, */ +// Regex code is obtained from SO: https://stackoverflow.com/questions/3143070/javascript-regex-iso-datetime#answer-3143231 +var ISO_DATE_REGEXP = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/; var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; +var DATE_REGEXP = /^(\d{4})-(\d{2})-(\d{2})$/; +var DATETIMELOCAL_REGEXP = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d))?$/; +var WEEK_REGEXP = /^(\d{4})-W(\d\d)$/; +var MONTH_REGEXP = /^(\d{4})-(\d\d)$/; +var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d))?$/; +var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; + +var $ngModelMinErr = new minErr('ngModel'); var inputType = { @@ -16433,7 +17465,9 @@ var inputType = { * @name input[text] * * @description - * Standard HTML text input with angular data binding. + * Standard HTML text input with angular data binding, inherited by most of the `input` elements. + * + * *NOTE* Not every feature offered is available for all input types. * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. @@ -16451,6 +17485,8 @@ var inputType = { * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + * This parameter is ignored for input[type=password] controls, which will never trim the + * input. * * @example @@ -16506,6 +17542,447 @@ var inputType = { */ 'text': textInputType, + /** + * @ngdoc input + * @name input[date] + * + * @description + * Input with date validation and transformation. In browsers that do not yet support + * the HTML5 date input, a text element will be used. In that case, text must be entered in a valid ISO-8601 + * date format (yyyy-MM-dd), for example: `2009-01-06`. Since many + * modern browsers do not yet support this input type, it is important to provide cues to users on the + * expected input format via a placeholder or label. The model must always be a Date object. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO date string (yyyy-MM-dd). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be + * a valid ISO date string (yyyy-MM-dd). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ Pick a date in 2013: + + + Required! + + Not a valid date! + value = {{value | date: "yyyy-MM-dd"}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+
+
+ + var value = element(by.binding('value | date: "yyyy-MM-dd"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (see https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-10-22'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('2015-01-01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
+ */ + 'date': createDateInputType('date', DATE_REGEXP, + createDateParser(DATE_REGEXP, ['yyyy', 'MM', 'dd']), + 'yyyy-MM-dd'), + + /** + * @ngdoc input + * @name input[dateTimeLocal] + * + * @description + * Input with datetime validation and transformation. In browsers that do not yet support + * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * local datetime format (yyyy-MM-ddTHH:mm:ss), for example: `2010-12-28T14:57:00`. The model must be a Date object. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO datetime format (yyyy-MM-ddTHH:mm:ss). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be + * a valid ISO datetime format (yyyy-MM-ddTHH:mm:ss). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ Pick a date between in 2013: + + + Required! + + Not a valid date! + value = {{value | date: "yyyy-MM-ddTHH:mm:ss"}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+
+
+ + var value = element(by.binding('value | date: "yyyy-MM-ddTHH:mm:ss"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('2010-12-28T14:57:00'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('2015-01-01T23:59:00'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
+ */ + 'datetime-local': createDateInputType('datetimelocal', DATETIMELOCAL_REGEXP, + createDateParser(DATETIMELOCAL_REGEXP, ['yyyy', 'MM', 'dd', 'HH', 'mm', 'ss']), + 'yyyy-MM-ddTHH:mm:ss'), + + /** + * @ngdoc input + * @name input[time] + * + * @description + * Input with time validation and transformation. In browsers that do not yet support + * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * local time format (HH:mm:ss), for example: `14:57:00`. Model must be a Date object. This binding will always output a + * Date object to the model of January 1, 1970, or local date `new Date(1970, 0, 1, HH, mm, ss)`. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO time format (HH:mm:ss). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be a + * valid ISO time format (HH:mm:ss). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ Pick a between 8am and 5pm: + + + Required! + + Not a valid date! + value = {{value | date: "HH:mm:ss"}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+
+
+ + var value = element(by.binding('value | date: "HH:mm:ss"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('14:57:00'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('23:59:00'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
+ */ + 'time': createDateInputType('time', TIME_REGEXP, + createDateParser(TIME_REGEXP, ['HH', 'mm', 'ss']), + 'HH:mm:ss'), + + /** + * @ngdoc input + * @name input[week] + * + * @description + * Input with week-of-the-year validation and transformation to Date. In browsers that do not yet support + * the HTML5 week input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * week format (yyyy-W##), for example: `2013-W02`. The model must always be a Date object. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO week format (yyyy-W##). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be + * a valid ISO week format (yyyy-W##). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ Pick a date between in 2013: + + + Required! + + Not a valid date! + value = {{value | date: "yyyy-Www"}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+
+
+ + var value = element(by.binding('value | date: "yyyy-Www"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-W01'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('2015-W01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
+ */ + 'week': createDateInputType('week', WEEK_REGEXP, weekParser, 'yyyy-Www'), + + /** + * @ngdoc input + * @name input[month] + * + * @description + * Input with month validation and transformation. In browsers that do not yet support + * the HTML5 month input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * month format (yyyy-MM), for example: `2009-01`. The model must always be a Date object. In the event the model is + * not set to the first of the month, the first of that model's month is assumed. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be + * a valid ISO month format (yyyy-MM). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must + * be a valid ISO month format (yyyy-MM). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
+ Pick a month int 2013: + + + Required! + + Not a valid month! + value = {{value | date: "yyyy-MM"}}
+ myForm.input.$valid = {{myForm.input.$valid}}
+ myForm.input.$error = {{myForm.input.$error}}
+ myForm.$valid = {{myForm.$valid}}
+ myForm.$error.required = {{!!myForm.$error.required}}
+
+
+ + var value = element(by.binding('value | date: "yyyy-MM"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-10'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('2015-01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
+ */ + 'month': createDateInputType('month', MONTH_REGEXP, + createDateParser(MONTH_REGEXP, ['yyyy', 'MM']), + 'yyyy-MM'), /** * @ngdoc input @@ -16799,8 +18276,8 @@ var inputType = { * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. - * @param {string=} ngTrueValue The value to which the expression should be set when selected. - * @param {string=} ngFalseValue The value to which the expression should be set when not selected. + * @param {expression=} ngTrueValue The value to which the expression should be set when selected. + * @param {expression=} ngFalseValue The value to which the expression should be set when not selected. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. * @@ -16817,7 +18294,7 @@ var inputType = {
Value1:
Value2:
+ ng-true-value="'YES'" ng-false-value="'NO'">
value1 = {{value1}}
value2 = {{value2}}
@@ -16848,13 +18325,6 @@ var inputType = { 'file': noop }; -// A helper function to call $setValidity and return the value / undefined, -// a pattern that is repeated a lot in the input validation logic. -function validate(ctrl, validatorName, validity, value){ - ctrl.$setValidity(validatorName, validity); - return validity ? value : undefined; -} - function testFlags(validity, flags) { var i, flag; if (flags) { @@ -16868,29 +18338,21 @@ function testFlags(validity, flags) { return false; } -// Pass validity so that behaviour can be mocked easier. -function addNativeHtml5Validators(ctrl, validatorName, badFlags, ignoreFlags, validity) { - if (isObject(validity)) { - ctrl.$$hasNativeValidators = true; - var validator = function(value) { - // Don't overwrite previous validation, don't consider valueMissing to apply (ng-required can - // perform the required validation) - if (!ctrl.$error[validatorName] && - !testFlags(validity, ignoreFlags) && - testFlags(validity, badFlags)) { - ctrl.$setValidity(validatorName, false); - return; - } - return value; - }; - ctrl.$parsers.push(validator); - } +function stringBasedInputType(ctrl) { + ctrl.$formatters.push(function(value) { + return ctrl.$isEmpty(value) ? value : value.toString(); + }); } function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); +} + +function baseInputType(scope, element, attr, ctrl, $sniffer, $browser) { var validity = element.prop(VALIDITY_STATE_PROPERTY); var placeholder = element[0].placeholder, noevent = {}; - ctrl.$$validityState = validity; + var type = lowercase(element[0].type); // In composition mode, users are still inputing intermediate text buffer, // hold the listener until composition is done. @@ -16910,7 +18372,8 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { var listener = function(ev) { if (composing) return; - var value = element.val(); + var value = element.val(), + event = ev && ev.type; // IE (11 and under) seem to emit an 'input' event if the placeholder value changes. // We don't want to dirty the value when this happens, so we abort here. Unfortunately, @@ -16923,23 +18386,16 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { // By default we will trim the value // If the attribute ng-trim exists we will avoid trimming - // e.g. - if (toBoolean(attr.ngTrim || 'T')) { + // If input type is 'password', the value is never trimmed + if (type !== 'password' && (!attr.ngTrim || attr.ngTrim !== 'false')) { value = trim(value); } - // If a control is suffering from bad input, browsers discard its value, so it may be - // necessary to revalidate even if the control's value is the same empty value twice in - // a row. - var revalidate = validity && ctrl.$$hasNativeValidators; - if (ctrl.$viewValue !== value || (value === '' && revalidate)) { - if (scope.$$phase) { - ctrl.$setViewValue(value); - } else { - scope.$apply(function() { - ctrl.$setViewValue(value); - }); - } + // If a control is suffering from bad input (due to native validators), browsers discard its + // value, so it may be necessary to revalidate (by calling $setViewValue again) even if the + // control's value is the same empty value twice in a row. + if (ctrl.$viewValue !== value || (value === '' && ctrl.$$hasNativeValidators)) { + ctrl.$setViewValue(value, event); } }; @@ -16950,10 +18406,10 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { } else { var timeout; - var deferListener = function() { + var deferListener = function(ev) { if (!timeout) { timeout = $browser.defer(function() { - listener(); + listener(ev); timeout = null; }); } @@ -16966,7 +18422,7 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { // command modifiers arrows if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; - deferListener(); + deferListener(event); }); // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it @@ -16982,129 +18438,213 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { ctrl.$render = function() { element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); }; +} - // pattern validator - var pattern = attr.ngPattern, - patternValidator, - match; +function weekParser(isoWeek) { + if (isDate(isoWeek)) { + return isoWeek; + } - if (pattern) { - var validateRegex = function(regexp, value) { - return validate(ctrl, 'pattern', ctrl.$isEmpty(value) || regexp.test(value), value); - }; - match = pattern.match(/^\/(.*)\/([gim]*)$/); - if (match) { - pattern = new RegExp(match[1], match[2]); - patternValidator = function(value) { - return validateRegex(pattern, value); - }; - } else { - patternValidator = function(value) { - var patternObj = scope.$eval(pattern); + if (isString(isoWeek)) { + WEEK_REGEXP.lastIndex = 0; + var parts = WEEK_REGEXP.exec(isoWeek); + if (parts) { + var year = +parts[1], + week = +parts[2], + firstThurs = getFirstThursdayOfYear(year), + addDays = (week - 1) * 7; + return new Date(year, 0, firstThurs.getDate() + addDays); + } + } - if (!patternObj || !patternObj.test) { - throw minErr('ngPattern')('noregexp', - 'Expected {0} to be a RegExp but was {1}. Element: {2}', pattern, - patternObj, startingTag(element)); - } - return validateRegex(patternObj, value); - }; + return NaN; +} + +function createDateParser(regexp, mapping) { + return function(iso) { + var parts, map; + + if (isDate(iso)) { + return iso; } - ctrl.$formatters.push(patternValidator); - ctrl.$parsers.push(patternValidator); - } + if (isString(iso)) { + // When a date is JSON'ified to wraps itself inside of an extra + // set of double quotes. This makes the date parsing code unable + // to match the date string and parse it as a date. + if (iso.charAt(0) == '"' && iso.charAt(iso.length-1) == '"') { + iso = iso.substring(1, iso.length-1); + } + if (ISO_DATE_REGEXP.test(iso)) { + return new Date(iso); + } + regexp.lastIndex = 0; + parts = regexp.exec(iso); - // min length validator - if (attr.ngMinlength) { - var minlength = int(attr.ngMinlength); - var minLengthValidator = function(value) { - return validate(ctrl, 'minlength', ctrl.$isEmpty(value) || value.length >= minlength, value); - }; + if (parts) { + parts.shift(); + map = { yyyy: 1970, MM: 1, dd: 1, HH: 0, mm: 0, ss: 0 }; - ctrl.$parsers.push(minLengthValidator); - ctrl.$formatters.push(minLengthValidator); - } + forEach(parts, function(part, index) { + if (index < mapping.length) { + map[mapping[index]] = +part; + } + }); + return new Date(map.yyyy, map.MM - 1, map.dd, map.HH, map.mm, map.ss || 0); + } + } - // max length validator - if (attr.ngMaxlength) { - var maxlength = int(attr.ngMaxlength); - var maxLengthValidator = function(value) { - return validate(ctrl, 'maxlength', ctrl.$isEmpty(value) || value.length <= maxlength, value); - }; + return NaN; + }; +} - ctrl.$parsers.push(maxLengthValidator); - ctrl.$formatters.push(maxLengthValidator); +function createDateInputType(type, regexp, parseDate, format) { + return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { + badInputChecker(scope, element, attr, ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + var timezone = ctrl && ctrl.$options && ctrl.$options.timezone; + + ctrl.$$parserName = type; + ctrl.$parsers.push(function(value) { + if (ctrl.$isEmpty(value)) return null; + if (regexp.test(value)) { + var parsedDate = parseDate(value); + if (timezone === 'UTC') { + parsedDate.setMinutes(parsedDate.getMinutes() - parsedDate.getTimezoneOffset()); + } + return parsedDate; + } + return undefined; + }); + + ctrl.$formatters.push(function(value) { + if (isDate(value)) { + return $filter('date')(value, format, timezone); + } + return ''; + }); + + if (isDefined(attr.min) || attr.ngMin) { + var minVal; + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(minVal) || parseDate(value) >= minVal; + }; + attr.$observe('min', function(val) { + minVal = parseObservedDateValue(val); + ctrl.$validate(); + }); + } + + if (isDefined(attr.max) || attr.ngMax) { + var maxVal; + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(maxVal) || parseDate(value) <= maxVal; + }; + attr.$observe('max', function(val) { + maxVal = parseObservedDateValue(val); + ctrl.$validate(); + }); + } + + function parseObservedDateValue(val) { + return isDefined(val) ? (isDate(val) ? val : parseDate(val)) : undefined; + } + }; +} + +function badInputChecker(scope, element, attr, ctrl) { + var node = element[0]; + var nativeValidation = ctrl.$$hasNativeValidators = isObject(node.validity); + if (nativeValidation) { + ctrl.$parsers.push(function(value) { + var validity = element.prop(VALIDITY_STATE_PROPERTY) || {}; + // Detect bug in FF35 for input[email] (https://bugzilla.mozilla.org/show_bug.cgi?id=1064430): + // - also sets validity.badInput (should only be validity.typeMismatch). + // - see http://www.whatwg.org/specs/web-apps/current-work/multipage/forms.html#e-mail-state-(type=email) + // - can ignore this case as we can still read out the erroneous email... + return validity.badInput && !validity.typeMismatch ? undefined : value; + }); } } -var numberBadFlags = ['badInput']; - function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); + badInputChecker(scope, element, attr, ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + ctrl.$$parserName = 'number'; ctrl.$parsers.push(function(value) { - var empty = ctrl.$isEmpty(value); - if (empty || NUMBER_REGEXP.test(value)) { - ctrl.$setValidity('number', true); - return value === '' ? null : (empty ? value : parseFloat(value)); - } else { - ctrl.$setValidity('number', false); - return undefined; + if (ctrl.$isEmpty(value)) return null; + if (NUMBER_REGEXP.test(value)) return parseFloat(value); + return undefined; + }); + + ctrl.$formatters.push(function(value) { + if (!ctrl.$isEmpty(value)) { + if (!isNumber(value)) { + throw $ngModelMinErr('numfmt', 'Expected `{0}` to be a number', value); + } + value = value.toString(); } + return value; }); - addNativeHtml5Validators(ctrl, 'number', numberBadFlags, null, ctrl.$$validityState); - - ctrl.$formatters.push(function(value) { - return ctrl.$isEmpty(value) ? '' : '' + value; - }); - - if (attr.min) { - var minValidator = function(value) { - var min = parseFloat(attr.min); - return validate(ctrl, 'min', ctrl.$isEmpty(value) || value >= min, value); + if (attr.min || attr.ngMin) { + var minVal; + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(minVal) || value >= minVal; }; - ctrl.$parsers.push(minValidator); - ctrl.$formatters.push(minValidator); + attr.$observe('min', function(val) { + if (isDefined(val) && !isNumber(val)) { + val = parseFloat(val, 10); + } + minVal = isNumber(val) && !isNaN(val) ? val : undefined; + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + }); } - if (attr.max) { - var maxValidator = function(value) { - var max = parseFloat(attr.max); - return validate(ctrl, 'max', ctrl.$isEmpty(value) || value <= max, value); + if (attr.max || attr.ngMax) { + var maxVal; + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(maxVal) || value <= maxVal; }; - ctrl.$parsers.push(maxValidator); - ctrl.$formatters.push(maxValidator); + attr.$observe('max', function(val) { + if (isDefined(val) && !isNumber(val)) { + val = parseFloat(val, 10); + } + maxVal = isNumber(val) && !isNaN(val) ? val : undefined; + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + }); } - - ctrl.$formatters.push(function(value) { - return validate(ctrl, 'number', ctrl.$isEmpty(value) || isNumber(value), value); - }); } function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); + // Note: no badInputChecker here by purpose as `url` is only a validation + // in browsers, i.e. we can always read out input.value even if it is not valid! + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); - var urlValidator = function(value) { - return validate(ctrl, 'url', ctrl.$isEmpty(value) || URL_REGEXP.test(value), value); + ctrl.$$parserName = 'url'; + ctrl.$validators.url = function(modelValue, viewValue) { + var value = modelValue || viewValue; + return ctrl.$isEmpty(value) || URL_REGEXP.test(value); }; - - ctrl.$formatters.push(urlValidator); - ctrl.$parsers.push(urlValidator); } function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); + // Note: no badInputChecker here by purpose as `url` is only a validation + // in browsers, i.e. we can always read out input.value even if it is not valid! + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); - var emailValidator = function(value) { - return validate(ctrl, 'email', ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value), value); + ctrl.$$parserName = 'email'; + ctrl.$validators.email = function(modelValue, viewValue) { + var value = modelValue || viewValue; + return ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value); }; - - ctrl.$formatters.push(emailValidator); - ctrl.$parsers.push(emailValidator); } function radioInputType(scope, element, attr, ctrl) { @@ -17113,13 +18653,13 @@ function radioInputType(scope, element, attr, ctrl) { element.attr('name', nextUid()); } - element.on('click', function() { + var listener = function(ev) { if (element[0].checked) { - scope.$apply(function() { - ctrl.$setViewValue(attr.value); - }); + ctrl.$setViewValue(attr.value, ev && ev.type); } - }); + }; + + element.on('click', listener); ctrl.$render = function() { var value = attr.value; @@ -17129,18 +18669,28 @@ function radioInputType(scope, element, attr, ctrl) { attr.$observe('value', ctrl.$render); } -function checkboxInputType(scope, element, attr, ctrl) { - var trueValue = attr.ngTrueValue, - falseValue = attr.ngFalseValue; +function parseConstantExpr($parse, context, name, expression, fallback) { + var parseFn; + if (isDefined(expression)) { + parseFn = $parse(expression); + if (!parseFn.constant) { + throw minErr('ngModel')('constexpr', 'Expected constant expression for `{0}`, but saw ' + + '`{1}`.', name, expression); + } + return parseFn(context); + } + return fallback; +} - if (!isString(trueValue)) trueValue = true; - if (!isString(falseValue)) falseValue = false; +function checkboxInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter, $parse) { + var trueValue = parseConstantExpr($parse, scope, 'ngTrueValue', attr.ngTrueValue, true); + var falseValue = parseConstantExpr($parse, scope, 'ngFalseValue', attr.ngFalseValue, false); - element.on('click', function() { - scope.$apply(function() { - ctrl.$setViewValue(element[0].checked); - }); - }); + var listener = function(ev) { + ctrl.$setViewValue(element[0].checked, ev && ev.type); + }; + + element.on('click', listener); ctrl.$render = function() { element[0].checked = ctrl.$viewValue; @@ -17152,7 +18702,7 @@ function checkboxInputType(scope, element, attr, ctrl) { }; ctrl.$formatters.push(function(value) { - return value === trueValue; + return equals(value, trueValue); }); ctrl.$parsers.push(function(value) { @@ -17199,6 +18749,8 @@ function checkboxInputType(scope, element, attr, ctrl) { * HTML input element control with angular data-binding. Input control follows HTML5 input types * and polyfills the HTML5 validation behavior for older browsers. * + * *NOTE* Not every feature offered is available for all input types. + * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} required Sets `required` validation error key if the value is not entered. @@ -17212,6 +18764,9 @@ function checkboxInputType(scope, element, attr, ctrl) { * patterns defined as scope expressions. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + * This parameter is ignored for input[type=password] controls, which will never trim the + * input. * * @example @@ -17247,7 +18802,7 @@ function checkboxInputType(scope, element, attr, ctrl) {
- var user = element(by.binding('{{user}}')); + var user = element(by.exactBinding('user')); var userNameValid = element(by.binding('myForm.userName.$valid')); var lastNameValid = element(by.binding('myForm.lastName.$valid')); var lastNameError = element(by.binding('myForm.lastName.$error')); @@ -17301,14 +18856,15 @@ function checkboxInputType(scope, element, attr, ctrl) { */ -var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { +var inputDirective = ['$browser', '$sniffer', '$filter', '$parse', + function($browser, $sniffer, $filter, $parse) { return { restrict: 'E', - require: '?ngModel', - link: function(scope, element, attr, ctrl) { - if (ctrl) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl, $sniffer, - $browser); + require: ['?ngModel'], + link: function(scope, element, attr, ctrls) { + if (ctrls[0]) { + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], $sniffer, + $browser, $filter, $parse); } } }; @@ -17317,7 +18873,10 @@ var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { var VALID_CLASS = 'ng-valid', INVALID_CLASS = 'ng-invalid', PRISTINE_CLASS = 'ng-pristine', - DIRTY_CLASS = 'ng-dirty'; + DIRTY_CLASS = 'ng-dirty', + UNTOUCHED_CLASS = 'ng-untouched', + TOUCHED_CLASS = 'ng-touched', + PENDING_CLASS = 'ng-pending'; /** * @ngdoc type @@ -17346,12 +18905,61 @@ var VALID_CLASS = 'ng-valid', * ngModel.$formatters.push(formatter); * ``` * + * @property {Object.} $validators A collection of validators that are applied + * whenever the model value changes. The key value within the object refers to the name of the + * validator while the function refers to the validation operation. The validation operation is + * provided with the model value as an argument and must return a true or false value depending + * on the response of that validation. + * + * ```js + * ngModel.$validators.validCharacters = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * return /[0-9]+/.test(value) && + * /[a-z]+/.test(value) && + * /[A-Z]+/.test(value) && + * /\W+/.test(value); + * }; + * ``` + * + * @property {Object.} $asyncValidators A collection of validations that are expected to + * perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided + * is expected to return a promise when it is run during the model validation process. Once the promise + * is delivered then the validation status will be set to true when fulfilled and false when rejected. + * When the asynchronous validators are triggered, each of the validators will run in parallel and the model + * value will only be updated once all validators have been fulfilled. Also, keep in mind that all + * asynchronous validators will only run once all synchronous validators have passed. + * + * Please note that if $http is used then it is important that the server returns a success HTTP response code + * in order to fulfill the validation and a status level of `4xx` in order to reject the validation. + * + * ```js + * ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * + * // Lookup user by username + * return $http.get('/api/users/' + value). + * then(function resolved() { + * //username exists, this means validation fails + * return $q.reject('exists'); + * }, function rejected() { + * //username does not exist, therefore this validation passes + * return true; + * }); + * }; + * ``` + * + * @param {string} name The name of the validator. + * @param {Function} validationFn The validation function that will be run. + * * @property {Array.} $viewChangeListeners Array of functions to execute whenever the * view value has changed. It is called with no arguments, and its return value is ignored. * This can be used in place of additional $watches against the model value. * - * @property {Object} $error An object hash with all errors as keys. + * @property {Object} $error An object hash with all failing validator ids as keys. + * @property {Object} $pending An object hash with all pending validator ids as keys. * + * @property {boolean} $untouched True if control has not lost focus yet. + * @property {boolean} $touched True if control has lost focus. * @property {boolean} $pristine True if user has not interacted with the control yet. * @property {boolean} $dirty True if user has already interacted with the control. * @property {boolean} $valid True if there is no error. @@ -17398,7 +19006,7 @@ var VALID_CLASS = 'ng-valid', restrict: 'A', // only activate on element attribute require: '?ngModel', // get a hold of NgModelController link: function(scope, element, attrs, ngModel) { - if(!ngModel) return; // do nothing if no ng-model + if (!ngModel) return; // do nothing if no ng-model // Specify how UI should be updated ngModel.$render = function() { @@ -17416,7 +19024,7 @@ var VALID_CLASS = 'ng-valid', var html = element.html(); // When we clear the content editable the browser leaves a
behind // If strip-br attribute is provided then we strip this out - if( attrs.stripBr && html == '
' ) { + if ( attrs.stripBr && html == '
' ) { html = ''; } ngModel.$setViewValue(html); @@ -17458,26 +19066,58 @@ var VALID_CLASS = 'ng-valid', * * */ -var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', - function($scope, $exceptionHandler, $attr, $element, $parse, $animate) { +var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q', + function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q) { this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; + this.$validators = {}; + this.$asyncValidators = {}; this.$parsers = []; this.$formatters = []; this.$viewChangeListeners = []; + this.$untouched = true; + this.$touched = false; this.$pristine = true; this.$dirty = false; this.$valid = true; this.$invalid = false; + this.$error = {}; // keep invalid keys here + this.$$success = {}; // keep valid keys here + this.$pending = undefined; // keep pending keys here this.$name = $attr.name; - var ngModelGet = $parse($attr.ngModel), - ngModelSet = ngModelGet.assign; - if (!ngModelSet) { - throw minErr('ngModel')('nonassign', "Expression '{0}' is non-assignable. Element: {1}", - $attr.ngModel, startingTag($element)); - } + var parsedNgModel = $parse($attr.ngModel), + pendingDebounce = null, + ctrl = this; + + var ngModelGet = function ngModelGet() { + var modelValue = parsedNgModel($scope); + if (ctrl.$options && ctrl.$options.getterSetter && isFunction(modelValue)) { + modelValue = modelValue(); + } + return modelValue; + }; + + var ngModelSet = function ngModelSet(newValue) { + var getterSetter; + if (ctrl.$options && ctrl.$options.getterSetter && + isFunction(getterSetter = parsedNgModel($scope))) { + + getterSetter(ctrl.$modelValue); + } else { + parsedNgModel.assign($scope, ctrl.$modelValue); + } + }; + + this.$$setOptions = function(options) { + ctrl.$options = options; + + if (!parsedNgModel.assign && (!options || !options.getterSetter)) { + throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", + $attr.ngModel, startingTag($element)); + } + }; /** * @ngdoc method @@ -17486,6 +19126,18 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * @description * Called when the view needs to be updated. It is expected that the user of the ng-model * directive will implement this method. + * + * The `$render()` method is invoked in the following situations: + * + * * `$rollbackViewValue()` is called. If we are rolling back the view value to the last + * committed value then `$render()` is called to update the input control. + * * The value referenced by `ng-model` is changed programmatically and both the `$modelValue` and + * the `$viewValue` are different to last time. + * + * Since `ng-model` does not do a deep watch, `$render()` is only invoked if the values of + * `$modelValue` and `$viewValue` are actually different to their previous value. If `$modelValue` + * or `$viewValue` are objects (rather than a string or number) then `$render()` will not be + * invoked if you only change a property on the objects. */ this.$render = noop; @@ -17511,63 +19163,44 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ }; var parentForm = $element.inheritedData('$formController') || nullFormCtrl, - invalidCount = 0, // used to easily determine if we are valid - $error = this.$error = {}; // keep invalid keys here - + currentValidationRunId = 0; // Setup initial state of the control - $element.addClass(PRISTINE_CLASS); - toggleValidCss(true); - - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $animate.removeClass($element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); - $animate.addClass($element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); - } + $element + .addClass(PRISTINE_CLASS) + .addClass(UNTOUCHED_CLASS); /** * @ngdoc method * @name ngModel.NgModelController#$setValidity * * @description - * Change the validity state, and notifies the form when the control changes validity. (i.e. it - * does not notify form if given validator is already marked as invalid). + * Change the validity state, and notifies the form. * - * This method should be called by validators - i.e. the parser or formatter functions. + * This method can be called within $parsers/$formatters. However, if possible, please use the + * `ngModel.$validators` pipeline which is designed to call this method automatically. * * @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign - * to `$error[validationErrorKey]=!isValid` so that it is available for data-binding. + * to `$error[validationErrorKey]` and `$pending[validationErrorKey]` + * so that it is available for data-binding. * The `validationErrorKey` should be in camelCase and will get converted into dash-case * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` * class and can be bound to as `{{someForm.someControl.$error.myError}}` . - * @param {boolean} isValid Whether the current state is valid (true) or invalid (false). + * @param {boolean} isValid Whether the current state is valid (true), invalid (false), pending (undefined), + * or skipped (null). */ - this.$setValidity = function(validationErrorKey, isValid) { - // Purposeful use of ! here to cast isValid to boolean in case it is undefined - // jshint -W018 - if ($error[validationErrorKey] === !isValid) return; - // jshint +W018 - - if (isValid) { - if ($error[validationErrorKey]) invalidCount--; - if (!invalidCount) { - toggleValidCss(true); - this.$valid = true; - this.$invalid = false; - } - } else { - toggleValidCss(false); - this.$invalid = true; - this.$valid = false; - invalidCount++; - } - - $error[validationErrorKey] = !isValid; - toggleValidCss(isValid, validationErrorKey); - - parentForm.$setValidity(validationErrorKey, isValid, this); - }; + addSetValidityMethod({ + ctrl: this, + $element: $element, + set: function(object, property) { + object[property] = true; + }, + unset: function(object, property) { + delete object[property]; + }, + parentForm: parentForm, + $animate: $animate + }); /** * @ngdoc method @@ -17577,15 +19210,299 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * Sets the control to its pristine state. * * This method can be called to remove the 'ng-dirty' class and set the control to its pristine - * state (ng-pristine class). + * state (ng-pristine class). A model is considered to be pristine when the model has not been changed + * from when first compiled within then form. */ this.$setPristine = function () { - this.$dirty = false; - this.$pristine = true; + ctrl.$dirty = false; + ctrl.$pristine = true; $animate.removeClass($element, DIRTY_CLASS); $animate.addClass($element, PRISTINE_CLASS); }; + /** + * @ngdoc method + * @name ngModel.NgModelController#$setUntouched + * + * @description + * Sets the control to its untouched state. + * + * This method can be called to remove the 'ng-touched' class and set the control to its + * untouched state (ng-untouched class). Upon compilation, a model is set as untouched + * by default, however this function can be used to restore that state if the model has + * already been touched by the user. + */ + this.$setUntouched = function() { + ctrl.$touched = false; + ctrl.$untouched = true; + $animate.setClass($element, UNTOUCHED_CLASS, TOUCHED_CLASS); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setTouched + * + * @description + * Sets the control to its touched state. + * + * This method can be called to remove the 'ng-untouched' class and set the control to its + * touched state (ng-touched class). A model is considered to be touched when the user has + * first interacted (focussed) on the model input element and then shifted focus away (blurred) + * from the input element. + */ + this.$setTouched = function() { + ctrl.$touched = true; + ctrl.$untouched = false; + $animate.setClass($element, TOUCHED_CLASS, UNTOUCHED_CLASS); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$rollbackViewValue + * + * @description + * Cancel an update and reset the input element's value to prevent an update to the `$modelValue`, + * which may be caused by a pending debounced event or because the input is waiting for a some + * future event. + * + * If you have an input that uses `ng-model-options` to set up debounced events or events such + * as blur you can have a situation where there is a period when the `$viewValue` + * is out of synch with the ngModel's `$modelValue`. + * + * In this case, you can run into difficulties if you try to update the ngModel's `$modelValue` + * programmatically before these debounced/future events have resolved/occurred, because Angular's + * dirty checking mechanism is not able to tell whether the model has actually changed or not. + * + * The `$rollbackViewValue()` method should be called before programmatically changing the model of an + * input which may have such events pending. This is important in order to make sure that the + * input field will be updated with the new model value and any pending operations are cancelled. + * + * + * + * angular.module('cancel-update-example', []) + * + * .controller('CancelUpdateController', ['$scope', function($scope) { + * $scope.resetWithCancel = function (e) { + * if (e.keyCode == 27) { + * $scope.myForm.myInput1.$rollbackViewValue(); + * $scope.myValue = ''; + * } + * }; + * $scope.resetWithoutCancel = function (e) { + * if (e.keyCode == 27) { + * $scope.myValue = ''; + * } + * }; + * }]); + * + * + *
+ *

Try typing something in each input. See that the model only updates when you + * blur off the input. + *

+ *

Now see what happens if you start typing then press the Escape key

+ * + *
+ *

With $rollbackViewValue()

+ *
+ * myValue: "{{ myValue }}" + * + *

Without $rollbackViewValue()

+ *
+ * myValue: "{{ myValue }}" + *
+ *
+ *
+ *
+ */ + this.$rollbackViewValue = function() { + $timeout.cancel(pendingDebounce); + ctrl.$viewValue = ctrl.$$lastCommittedViewValue; + ctrl.$render(); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$validate + * + * @description + * Runs each of the registered validators (first synchronous validators and then asynchronous validators). + */ + this.$validate = function() { + // ignore $validate before model is initialized + if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { + return; + } + this.$$parseAndValidate(); + }; + + this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) { + currentValidationRunId++; + var localValidationRunId = currentValidationRunId; + + // check parser error + if (!processParseErrors(parseValid)) { + return; + } + if (!processSyncValidators()) { + return; + } + processAsyncValidators(); + + function processParseErrors(parseValid) { + var errorKey = ctrl.$$parserName || 'parse'; + if (parseValid === undefined) { + setValidity(errorKey, null); + } else { + setValidity(errorKey, parseValid); + if (!parseValid) { + forEach(ctrl.$validators, function(v, name) { + setValidity(name, null); + }); + forEach(ctrl.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + validationDone(); + return false; + } + } + return true; + } + + function processSyncValidators() { + var syncValidatorsValid = true; + forEach(ctrl.$validators, function(validator, name) { + var result = validator(modelValue, viewValue); + syncValidatorsValid = syncValidatorsValid && result; + setValidity(name, result); + }); + if (!syncValidatorsValid) { + forEach(ctrl.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + validationDone(); + return false; + } + return true; + } + + function processAsyncValidators() { + var validatorPromises = []; + forEach(ctrl.$asyncValidators, function(validator, name) { + var promise = validator(modelValue, viewValue); + if (!isPromiseLike(promise)) { + throw $ngModelMinErr("$asyncValidators", + "Expected asynchronous validator to return a promise but got '{0}' instead.", promise); + } + setValidity(name, undefined); + validatorPromises.push(promise.then(function() { + setValidity(name, true); + }, function(error) { + setValidity(name, false); + })); + }); + if (!validatorPromises.length) { + validationDone(); + } else { + $q.all(validatorPromises).then(validationDone); + } + } + + function setValidity(name, isValid) { + if (localValidationRunId === currentValidationRunId) { + ctrl.$setValidity(name, isValid); + } + } + + function validationDone() { + if (localValidationRunId === currentValidationRunId) { + + doneCallback(); + } + } + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$commitViewValue + * + * @description + * Commit a pending update to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. this method is rarely needed as `NgModelController` + * usually handles calling this in response to input events. + */ + this.$commitViewValue = function() { + var viewValue = ctrl.$viewValue; + + $timeout.cancel(pendingDebounce); + + // If the view value has not changed then we should just exit, except in the case where there is + // a native validator on the element. In this case the validation state may have changed even though + // the viewValue has stayed empty. + if (ctrl.$$lastCommittedViewValue === viewValue && (viewValue !== '' || !ctrl.$$hasNativeValidators)) { + return; + } + ctrl.$$lastCommittedViewValue = viewValue; + + // change to dirty + if (ctrl.$pristine) { + ctrl.$dirty = true; + ctrl.$pristine = false; + $animate.removeClass($element, PRISTINE_CLASS); + $animate.addClass($element, DIRTY_CLASS); + parentForm.$setDirty(); + } + this.$$parseAndValidate(); + }; + + this.$$parseAndValidate = function() { + var parserValid = true, + viewValue = ctrl.$$lastCommittedViewValue, + modelValue = viewValue; + for(var i = 0; i < ctrl.$parsers.length; i++) { + modelValue = ctrl.$parsers[i](modelValue); + if (isUndefined(modelValue)) { + parserValid = false; + break; + } + } + if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { + // ctrl.$modelValue has not been touched yet... + ctrl.$modelValue = ngModelGet(); + } + var prevModelValue = ctrl.$modelValue; + var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid; + if (allowInvalid) { + ctrl.$modelValue = modelValue; + writeToModelIfNeeded(); + } + ctrl.$$runValidators(parserValid, modelValue, viewValue, function() { + if (!allowInvalid) { + ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; + writeToModelIfNeeded(); + } + }); + + function writeToModelIfNeeded() { + if (ctrl.$modelValue !== prevModelValue) { + ctrl.$$writeModelToScope(); + } + } + }; + + this.$$writeModelToScope = function() { + ngModelSet(ctrl.$modelValue); + forEach(ctrl.$viewChangeListeners, function(listener) { + try { + listener(); + } catch(e) { + $exceptionHandler(e); + } + }); + }; + /** * @ngdoc method * @name ngModel.NgModelController#$setViewValue @@ -17593,73 +19510,108 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * @description * Update the view value. * - * This method should be called when the view value changes, typically from within a DOM event handler. - * For example {@link ng.directive:input input} and - * {@link ng.directive:select select} directives call it. + * This method should be called when an input directive want to change the view value; typically, + * this is done from within a DOM event handler. * - * It will update the $viewValue, then pass this value through each of the functions in `$parsers`, - * which includes any validators. The value that comes out of this `$parsers` pipeline, be applied to - * `$modelValue` and the **expression** specified in the `ng-model` attribute. + * For example {@link ng.directive:input input} calls it when the value of the input changes and + * {@link ng.directive:select select} calls it when an option is selected. + * + * If the new `value` is an object (rather than a string or a number), we should make a copy of the + * object before passing it to `$setViewValue`. This is because `ngModel` does not perform a deep + * watch of objects, it only looks for a change of identity. If you only change the property of + * the object then ngModel will not realise that the object has changed and will not invoke the + * `$parsers` and `$validators` pipelines. + * + * For this reason, you should not change properties of the copy once it has been passed to + * `$setViewValue`. Otherwise you may cause the model value on the scope to change incorrectly. + * + * When this method is called, the new `value` will be staged for committing through the `$parsers` + * and `$validators` pipelines. If there are no special {@link ngModelOptions} specified then the staged + * value sent directly for processing, finally to be applied to `$modelValue` and then the + * **expression** specified in the `ng-model` attribute. * * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called. * + * In case the {@link ng.directive:ngModelOptions ngModelOptions} directive is used with `updateOn` + * and the `default` trigger is not listed, all those actions will remain pending until one of the + * `updateOn` events is triggered on the DOM element. + * All these actions will be debounced if the {@link ng.directive:ngModelOptions ngModelOptions} + * directive is used with a custom debounce for this particular event. + * * Note that calling this function does not trigger a `$digest`. * * @param {string} value Value from the view. + * @param {string} trigger Event that triggered the update. */ - this.$setViewValue = function(value) { - this.$viewValue = value; + this.$setViewValue = function(value, trigger) { + ctrl.$viewValue = value; + if (!ctrl.$options || ctrl.$options.updateOnDefault) { + ctrl.$$debounceViewValueCommit(trigger); + } + }; - // change to dirty - if (this.$pristine) { - this.$dirty = true; - this.$pristine = false; - $animate.removeClass($element, PRISTINE_CLASS); - $animate.addClass($element, DIRTY_CLASS); - parentForm.$setDirty(); + this.$$debounceViewValueCommit = function(trigger) { + var debounceDelay = 0, + options = ctrl.$options, + debounce; + + if (options && isDefined(options.debounce)) { + debounce = options.debounce; + if (isNumber(debounce)) { + debounceDelay = debounce; + } else if (isNumber(debounce[trigger])) { + debounceDelay = debounce[trigger]; + } else if (isNumber(debounce['default'])) { + debounceDelay = debounce['default']; + } } - forEach(this.$parsers, function(fn) { - value = fn(value); - }); - - if (this.$modelValue !== value) { - this.$modelValue = value; - ngModelSet($scope, value); - forEach(this.$viewChangeListeners, function(listener) { - try { - listener(); - } catch(e) { - $exceptionHandler(e); - } + $timeout.cancel(pendingDebounce); + if (debounceDelay) { + pendingDebounce = $timeout(function() { + ctrl.$commitViewValue(); + }, debounceDelay); + } else if ($rootScope.$$phase) { + ctrl.$commitViewValue(); + } else { + $scope.$apply(function() { + ctrl.$commitViewValue(); }); } }; // model -> value - var ctrl = this; - + // Note: we cannot use a normal scope.$watch as we want to detect the following: + // 1. scope value is 'a' + // 2. user enters 'b' + // 3. ng-change kicks in and reverts scope value to 'a' + // -> scope value did not change since the last digest as + // ng-change executes in apply phase + // 4. view should be changed back to 'a' $scope.$watch(function ngModelWatch() { - var value = ngModelGet($scope); + var modelValue = ngModelGet(); // if scope model value and ngModel value are out of sync - if (ctrl.$modelValue !== value) { + // TODO(perf): why not move this to the action fn? + if (modelValue !== ctrl.$modelValue) { + ctrl.$modelValue = modelValue; var formatters = ctrl.$formatters, idx = formatters.length; - ctrl.$modelValue = value; + var viewValue = modelValue; while(idx--) { - value = formatters[idx](value); + viewValue = formatters[idx](viewValue); } - - if (ctrl.$viewValue !== value) { - ctrl.$viewValue = value; + if (ctrl.$viewValue !== viewValue) { + ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue; ctrl.$render(); + + ctrl.$$runValidators(undefined, modelValue, viewValue, noop); } } - return value; + return modelValue; }); }]; @@ -17680,8 +19632,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * - Binding the view into the model, which other directives such as `input`, `textarea` or `select` * require. * - Providing validation behavior (i.e. required, number, email, url). - * - Keeping the state of the control (valid/invalid, dirty/pristine, validation errors). - * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`) including animations. + * - Keeping the state of the control (valid/invalid, dirty/pristine, touched/untouched, validation errors). + * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`, `ng-touched`, `ng-untouched`) including animations. * - Registering the control with its parent {@link ng.directive:form form}. * * Note: `ngModel` will try to bind to the property given by evaluating the expression on the @@ -17701,6 +19653,11 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * - {@link input[number] number} * - {@link input[email] email} * - {@link input[url] url} + * - {@link input[date] date} + * - {@link input[dateTimeLocal] dateTimeLocal} + * - {@link input[time] time} + * - {@link input[month] month} + * - {@link input[week] week} * - {@link ng.directive:select select} * - {@link ng.directive:textarea textarea} * @@ -17766,22 +19723,91 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * + * + * ## Binding to a getter/setter + * + * Sometimes it's helpful to bind `ngModel` to a getter/setter function. A getter/setter is a + * function that returns a representation of the model when called with zero arguments, and sets + * the internal state of a model when called with an argument. It's sometimes useful to use this + * for models that have an internal representation that's different than what the model exposes + * to the view. + * + *
+ * **Best Practice:** It's best to keep getters fast because Angular is likely to call them more + * frequently than other parts of your code. + *
+ * + * You use this behavior by adding `ng-model-options="{ getterSetter: true }"` to an element that + * has `ng-model` attached to it. You can also add `ng-model-options="{ getterSetter: true }"` to + * a `
`, which will enable this behavior for all ``s within it. See + * {@link ng.directive:ngModelOptions `ngModelOptions`} for more. + * + * The following example shows how to use `ngModel` with a getter/setter: + * + * @example + * + +
+ + Name: + + +
user.name = 
+
+
+ + angular.module('getterSetterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + var _name = 'Brian'; + $scope.user = { + name: function (newName) { + if (angular.isDefined(newName)) { + _name = newName; + } + return _name; + } + }; + }]); + + *
*/ var ngModelDirective = function() { return { - require: ['ngModel', '^?form'], + restrict: 'A', + require: ['ngModel', '^?form', '^?ngModelOptions'], controller: NgModelController, - link: function(scope, element, attr, ctrls) { - // notify others, especially parent forms + link: { + pre: function(scope, element, attr, ctrls) { + var modelCtrl = ctrls[0], + formCtrl = ctrls[1] || nullFormCtrl; - var modelCtrl = ctrls[0], - formCtrl = ctrls[1] || nullFormCtrl; + modelCtrl.$$setOptions(ctrls[2] && ctrls[2].$options); - formCtrl.$addControl(modelCtrl); + // notify others, especially parent forms + formCtrl.$addControl(modelCtrl); - scope.$on('$destroy', function() { - formCtrl.$removeControl(modelCtrl); - }); + scope.$on('$destroy', function() { + formCtrl.$removeControl(modelCtrl); + }); + }, + post: function(scope, element, attr, ctrls) { + var modelCtrl = ctrls[0]; + if (modelCtrl.$options && modelCtrl.$options.updateOn) { + element.on(modelCtrl.$options.updateOn, function(ev) { + modelCtrl.$$debounceViewValueCommit(ev && ev.type); + }); + } + + element.on('blur', function(ev) { + if (modelCtrl.$touched) return; + + scope.$apply(function() { + modelCtrl.$setTouched(); + }); + }); + } } }; }; @@ -17796,7 +19822,15 @@ var ngModelDirective = function() { * The expression is evaluated immediately, unlike the JavaScript onchange event * which only triggers at the end of a change (usually, when the user leaves the * form element or presses the return key). - * The expression is not evaluated when the value change is coming from the model. + * + * The `ngChange` expression is only evaluated when a change in the input value causes + * a new value to be committed to the model. + * + * It will not be evaluated: + * * if the value returned from the `$parsers` transformation pipeline has not changed + * * if the input has continued to be invalid since the model will stay `null` + * * if the model is changed programmatically and not by a change to the input value + * * * Note, this directive requires `ngModel` to be present. * @@ -17847,6 +19881,7 @@ var ngModelDirective = function() { * */ var ngChangeDirective = valueFn({ + restrict: 'A', require: 'ngModel', link: function(scope, element, attr, ctrl) { ctrl.$viewChangeListeners.push(function() { @@ -17858,93 +19893,187 @@ var ngChangeDirective = valueFn({ var requiredDirective = function() { return { + restrict: 'A', require: '?ngModel', link: function(scope, elm, attr, ctrl) { if (!ctrl) return; attr.required = true; // force truthy in case we are on non input element - var validator = function(value) { - if (attr.required && ctrl.$isEmpty(value)) { - ctrl.$setValidity('required', false); - return; - } else { - ctrl.$setValidity('required', true); - return value; - } + ctrl.$validators.required = function(modelValue, viewValue) { + return !attr.required || !ctrl.$isEmpty(viewValue); }; - ctrl.$formatters.push(validator); - ctrl.$parsers.unshift(validator); - attr.$observe('required', function() { - validator(ctrl.$viewValue); + ctrl.$validate(); }); } }; }; +var patternDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var regexp, patternExp = attr.ngPattern || attr.pattern; + attr.$observe('pattern', function(regex) { + if (isString(regex) && regex.length > 0) { + regex = new RegExp(regex); + } + + if (regex && !regex.test) { + throw minErr('ngPattern')('noregexp', + 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, + regex, startingTag(elm)); + } + + regexp = regex || undefined; + ctrl.$validate(); + }); + + ctrl.$validators.pattern = function(value) { + return ctrl.$isEmpty(value) || isUndefined(regexp) || regexp.test(value); + }; + } + }; +}; + + +var maxlengthDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var maxlength = 0; + attr.$observe('maxlength', function(value) { + maxlength = int(value) || 0; + ctrl.$validate(); + }); + ctrl.$validators.maxlength = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || viewValue.length <= maxlength; + }; + } + }; +}; + +var minlengthDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var minlength = 0; + attr.$observe('minlength', function(value) { + minlength = int(value) || 0; + ctrl.$validate(); + }); + ctrl.$validators.minlength = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || viewValue.length >= minlength; + }; + } + }; +}; + + /** * @ngdoc directive * @name ngList * * @description - * Text input that converts between a delimited string and an array of strings. The delimiter - * can be a fixed string (by default a comma) or a regular expression. + * Text input that converts between a delimited string and an array of strings. The default + * delimiter is a comma followed by a space - equivalent to `ng-list=", "`. You can specify a custom + * delimiter as the value of the `ngList` attribute - for example, `ng-list=" | "`. + * + * The behaviour of the directive is affected by the use of the `ngTrim` attribute. + * * If `ngTrim` is set to `"false"` then whitespace around both the separator and each + * list item is respected. This implies that the user of the directive is responsible for + * dealing with whitespace but also allows you to use whitespace as a delimiter, such as a + * tab or newline character. + * * Otherwise whitespace around the delimiter is ignored when splitting (although it is respected + * when joining the list items back together) and whitespace around each list item is stripped + * before it is added to the model. + * + * ### Example with Validation + * + * + * + * angular.module('listExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.names = ['morpheus', 'neo', 'trinity']; + * }]); + * + * + *
+ * List: + * + * Required! + *
+ * names = {{names}}
+ * myForm.namesInput.$valid = {{myForm.namesInput.$valid}}
+ * myForm.namesInput.$error = {{myForm.namesInput.$error}}
+ * myForm.$valid = {{myForm.$valid}}
+ * myForm.$error.required = {{!!myForm.$error.required}}
+ *
+ *
+ * + * var listInput = element(by.model('names')); + * var names = element(by.exactBinding('names')); + * var valid = element(by.binding('myForm.namesInput.$valid')); + * var error = element(by.css('span.error')); + * + * it('should initialize to model', function() { + * expect(names.getText()).toContain('["morpheus","neo","trinity"]'); + * expect(valid.getText()).toContain('true'); + * expect(error.getCssValue('display')).toBe('none'); + * }); + * + * it('should be invalid if empty', function() { + * listInput.clear(); + * listInput.sendKeys(''); + * + * expect(names.getText()).toContain(''); + * expect(valid.getText()).toContain('false'); + * expect(error.getCssValue('display')).not.toBe('none'); + * }); + * + *
+ * + * ### Example - splitting on whitespace + * + * + * + *
{{ list | json }}
+ *
+ * + * it("should split the text by newlines", function() { + * var listInput = element(by.model('list')); + * var output = element(by.binding('list | json')); + * listInput.sendKeys('abc\ndef\nghi'); + * expect(output.getText()).toContain('[\n "abc",\n "def",\n "ghi"\n]'); + * }); + * + *
* * @element input - * @param {string=} ngList optional delimiter that should be used to split the value. If - * specified in form `/something/` then the value will be converted into a regular expression. - * - * @example - - - -
- List: - - Required! -
- names = {{names}}
- myForm.namesInput.$valid = {{myForm.namesInput.$valid}}
- myForm.namesInput.$error = {{myForm.namesInput.$error}}
- myForm.$valid = {{myForm.$valid}}
- myForm.$error.required = {{!!myForm.$error.required}}
-
-
- - var listInput = element(by.model('names')); - var names = element(by.binding('{{names}}')); - var valid = element(by.binding('myForm.namesInput.$valid')); - var error = element(by.css('span.error')); - - it('should initialize to model', function() { - expect(names.getText()).toContain('["igor","misko","vojta"]'); - expect(valid.getText()).toContain('true'); - expect(error.getCssValue('display')).toBe('none'); - }); - - it('should be invalid if empty', function() { - listInput.clear(); - listInput.sendKeys(''); - - expect(names.getText()).toContain(''); - expect(valid.getText()).toContain('false'); - expect(error.getCssValue('display')).not.toBe('none'); }); - -
+ * @param {string=} ngList optional delimiter that should be used to split the value. */ var ngListDirective = function() { return { + restrict: 'A', + priority: 100, require: 'ngModel', link: function(scope, element, attr, ctrl) { - var match = /\/(.*)\//.exec(attr.ngList), - separator = match && new RegExp(match[1]) || attr.ngList || ','; + // We want to control whitespace trimming so we use this convoluted approach + // to access the ngList attribute, which doesn't pre-trim the attribute + var ngList = element.attr(attr.$attr.ngList) || ', '; + var trimValues = attr.ngTrim !== 'false'; + var separator = trimValues ? trim(ngList) : ngList; var parse = function(viewValue) { // If the viewValue is invalid (say required but empty) it will be `undefined` @@ -17954,7 +20083,7 @@ var ngListDirective = function() { if (viewValue) { forEach(viewValue.split(separator), function(value) { - if (value) list.push(trim(value)); + if (value) list.push(trimValues ? trim(value) : value); }); } @@ -17964,7 +20093,7 @@ var ngListDirective = function() { ctrl.$parsers.push(parse); ctrl.$formatters.push(function(value) { if (isArray(value)) { - return value.join(', '); + return value.join(ngList); } return undefined; @@ -18034,6 +20163,7 @@ var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/; */ var ngValueDirective = function() { return { + restrict: 'A', priority: 100, compile: function(tpl, tplAttr) { if (CONSTANT_VALUE_REGEXP.test(tplAttr.ngValue)) { @@ -18051,6 +20181,278 @@ var ngValueDirective = function() { }; }; +/** + * @ngdoc directive + * @name ngModelOptions + * + * @description + * Allows tuning how model updates are done. Using `ngModelOptions` you can specify a custom list of + * events that will trigger a model update and/or a debouncing delay so that the actual update only + * takes place when a timer expires; this timer will be reset after another change takes place. + * + * Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might + * be different than the value in the actual model. This means that if you update the model you + * should also invoke {@link ngModel.NgModelController `$rollbackViewValue`} on the relevant input field in + * order to make sure it is synchronized with the model and that any debounced action is canceled. + * + * The easiest way to reference the control's {@link ngModel.NgModelController `$rollbackViewValue`} + * method is by making sure the input is placed inside a form that has a `name` attribute. This is + * important because `form` controllers are published to the related scope under the name in their + * `name` attribute. + * + * Any pending changes will take place immediately when an enclosing form is submitted via the + * `submit` event. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` + * to have access to the updated model. + * + * @param {Object} ngModelOptions options to apply to the current model. Valid keys are: + * - `updateOn`: string specifying which event should be the input bound to. You can set several + * events using an space delimited list. There is a special event called `default` that + * matches the default events belonging of the control. + * - `debounce`: integer value which contains the debounce model update value in milliseconds. A + * value of 0 triggers an immediate update. If an object is supplied instead, you can specify a + * custom value for each event. For example: + * `ng-model-options="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }"` + * - `allowInvalid`: boolean value which indicates that the model can be set with values that did + * not validate correctly instead of the default behavior of setting the model to undefined. + * - `getterSetter`: boolean value which determines whether or not to treat functions bound to + `ngModel` as getters/setters. + * - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for + * ``, ``, ... . Right now, the only supported value is `'UTC'`, + * otherwise the default timezone of the browser will be used. + * + * @example + + The following example shows how to override immediate updates. Changes on the inputs within the + form will update the model only when the control loses focus (blur event). If `escape` key is + pressed while the input field is focused, the value is reset to the value in the current model. + + + +
+
+ Name: +
+ + Other data: +
+
+
user.name = 
+
+
+ + angular.module('optionsExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.user = { name: 'say', data: '' }; + + $scope.cancel = function (e) { + if (e.keyCode == 27) { + $scope.userForm.userName.$rollbackViewValue(); + } + }; + }]); + + + var model = element(by.binding('user.name')); + var input = element(by.model('user.name')); + var other = element(by.model('user.data')); + + it('should allow custom events', function() { + input.sendKeys(' hello'); + input.click(); + expect(model.getText()).toEqual('say'); + other.click(); + expect(model.getText()).toEqual('say hello'); + }); + + it('should $rollbackViewValue when model changes', function() { + input.sendKeys(' hello'); + expect(input.getAttribute('value')).toEqual('say hello'); + input.sendKeys(protractor.Key.ESCAPE); + expect(input.getAttribute('value')).toEqual('say'); + other.click(); + expect(model.getText()).toEqual('say'); + }); + +
+ + This one shows how to debounce model changes. Model will be updated only 1 sec after last change. + If the `Clear` button is pressed, any debounced action is canceled and the value becomes empty. + + + +
+
+ Name: + +
+
+
user.name = 
+
+
+ + angular.module('optionsExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.user = { name: 'say' }; + }]); + +
+ + This one shows how to bind to getter/setters: + + + +
+
+ Name: + +
+
user.name = 
+
+
+ + angular.module('getterSetterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + var _name = 'Brian'; + $scope.user = { + name: function (newName) { + return angular.isDefined(newName) ? (_name = newName) : _name; + } + }; + }]); + +
+ */ +var ngModelOptionsDirective = function() { + return { + restrict: 'A', + controller: ['$scope', '$attrs', function($scope, $attrs) { + var that = this; + this.$options = $scope.$eval($attrs.ngModelOptions); + // Allow adding/overriding bound events + if (this.$options.updateOn !== undefined) { + this.$options.updateOnDefault = false; + // extract "default" pseudo-event from list of events that can trigger a model update + this.$options.updateOn = trim(this.$options.updateOn.replace(DEFAULT_REGEXP, function() { + that.$options.updateOnDefault = true; + return ' '; + })); + } else { + this.$options.updateOnDefault = true; + } + }] + }; +}; + +// helper methods +function addSetValidityMethod(context) { + var ctrl = context.ctrl, + $element = context.$element, + classCache = {}, + set = context.set, + unset = context.unset, + parentForm = context.parentForm, + $animate = context.$animate; + + ctrl.$setValidity = setValidity; + toggleValidationCss('', true); + + function setValidity(validationErrorKey, state, options) { + if (state === undefined) { + createAndSet('$pending', validationErrorKey, options); + } else { + unsetAndCleanup('$pending', validationErrorKey, options); + } + if (!isBoolean(state)) { + unset(ctrl.$error, validationErrorKey, options); + unset(ctrl.$$success, validationErrorKey, options); + } else { + if (state) { + unset(ctrl.$error, validationErrorKey, options); + set(ctrl.$$success, validationErrorKey, options); + } else { + set(ctrl.$error, validationErrorKey, options); + unset(ctrl.$$success, validationErrorKey, options); + } + } + if (ctrl.$pending) { + cachedToggleClass(PENDING_CLASS, true); + ctrl.$valid = ctrl.$invalid = undefined; + toggleValidationCss('', null); + } else { + cachedToggleClass(PENDING_CLASS, false); + ctrl.$valid = isObjectEmpty(ctrl.$error); + ctrl.$invalid = !ctrl.$valid; + toggleValidationCss('', ctrl.$valid); + } + + // re-read the state as the set/unset methods could have + // combined state in ctrl.$error[validationError] (used for forms), + // where setting/unsetting only increments/decrements the value, + // and does not replace it. + var combinedState; + if (ctrl.$pending && ctrl.$pending[validationErrorKey]) { + combinedState = undefined; + } else if (ctrl.$error[validationErrorKey]) { + combinedState = false; + } else if (ctrl.$$success[validationErrorKey]) { + combinedState = true; + } else { + combinedState = null; + } + toggleValidationCss(validationErrorKey, combinedState); + parentForm.$setValidity(validationErrorKey, combinedState, ctrl); + } + + function createAndSet(name, value, options) { + if (!ctrl[name]) { + ctrl[name] = {}; + } + set(ctrl[name], value, options); + } + + function unsetAndCleanup(name, value, options) { + if (ctrl[name]) { + unset(ctrl[name], value, options); + } + if (isObjectEmpty(ctrl[name])) { + ctrl[name] = undefined; + } + } + + function cachedToggleClass(className, switchValue) { + if (switchValue && !classCache[className]) { + $animate.addClass($element, className); + classCache[className] = true; + } else if (!switchValue && classCache[className]) { + $animate.removeClass($element, className); + classCache[className] = false; + } + } + + function toggleValidationCss(validationErrorKey, isValid) { + validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; + + cachedToggleClass(VALID_CLASS + validationErrorKey, isValid === true); + cachedToggleClass(INVALID_CLASS + validationErrorKey, isValid === false); + } +} + +function isObjectEmpty(obj) { + if (obj) { + for (var prop in obj) { + return false; + } + } + return true; +} + /** * @ngdoc directive * @name ngBind @@ -18064,7 +20466,7 @@ var ngValueDirective = function() { * Typically, you don't use `ngBind` directly, but instead you use the double curly markup like * `{{ expression }}` which is similar but less verbose. * - * It is preferable to use `ngBind` instead of `{{ expression }}` when a template is momentarily + * It is preferable to use `ngBind` instead of `{{ expression }}` if a template is momentarily * displayed by the browser in its raw state before Angular compiles it. Since `ngBind` is an * element attribute, it makes the bindings invisible to the user while the page is loading. * @@ -18102,20 +20504,23 @@ var ngValueDirective = function() { */ -var ngBindDirective = ngDirective({ - compile: function(templateElement) { - templateElement.addClass('ng-binding'); - return function (scope, element, attr) { - element.data('$binding', attr.ngBind); - scope.$watch(attr.ngBind, function ngBindWatchAction(value) { - // We are purposefully using == here rather than === because we want to - // catch when value is "null or undefined" - // jshint -W041 - element.text(value == undefined ? '' : value); - }); - }; - } -}); +var ngBindDirective = ['$compile', function($compile) { + return { + restrict: 'AC', + compile: function ngBindCompile(templateElement) { + $compile.$$addBindingClass(templateElement); + return function ngBindLink(scope, element, attr) { + $compile.$$addBindingInfo(element, attr.ngBind); + scope.$watch(attr.ngBind, function ngBindWatchAction(value) { + // We are purposefully using == here rather than === because we want to + // catch when value is "null or undefined" + // jshint -W041 + element.text(value == undefined ? '' : value); + }); + }; + } + }; +}]; /** @@ -18169,14 +20574,18 @@ var ngBindDirective = ngDirective({ */ -var ngBindTemplateDirective = ['$interpolate', function($interpolate) { - return function(scope, element, attr) { - // TODO: move this to scenario runner - var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); - element.addClass('ng-binding').data('$binding', interpolateFn); - attr.$observe('ngBindTemplate', function(value) { - element.text(value); - }); +var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate, $compile) { + return { + compile: function ngBindTemplateCompile(templateElement) { + $compile.$$addBindingClass(templateElement); + return function ngBindTemplateLink(scope, element, attr) { + var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); + $compile.$$addBindingInfo(element, interpolateFn.expressions); + attr.$observe('ngBindTemplate', function(value) { + element.text(value); + }); + }; + } }; }]; @@ -18201,7 +20610,6 @@ var ngBindTemplateDirective = ['$interpolate', function($interpolate) { * @param {expression} ngBindHtml {@link guide/expression Expression} to evaluate. * * @example - Try it here: enter text in text box and watch the greeting change. @@ -18227,16 +20635,26 @@ var ngBindTemplateDirective = ['$interpolate', function($interpolate) { */ -var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) { - return function(scope, element, attr) { - element.addClass('ng-binding').data('$binding', attr.ngBindHtml); +var ngBindHtmlDirective = ['$sce', '$parse', '$compile', function($sce, $parse, $compile) { + return { + restrict: 'A', + compile: function ngBindHtmlCompile(tElement, tAttrs) { + var ngBindHtmlGetter = $parse(tAttrs.ngBindHtml); + var ngBindHtmlWatch = $parse(tAttrs.ngBindHtml, function getStringValue(value) { + return (value || '').toString(); + }); + $compile.$$addBindingClass(tElement); - var parsed = $parse(attr.ngBindHtml); - function getStringValue() { return (parsed(scope) || '').toString(); } + return function ngBindHtmlLink(scope, element, attr) { + $compile.$$addBindingInfo(element, attr.ngBindHtml); - scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) { - element.html($sce.getTrustedHtml(parsed(scope)) || ''); - }); + scope.$watch(ngBindHtmlWatch, function ngBindHtmlWatchAction() { + // we re-evaluate the expr because we want a TrustedValueHolderType + // for $sce, not a string + element.html($sce.getTrustedHtml(ngBindHtmlGetter(scope)) || ''); + }); + }; + } }; }]; @@ -18296,15 +20714,13 @@ function classDirective(name, selector) { function updateClasses (oldClasses, newClasses) { var toAdd = arrayDifference(newClasses, oldClasses); var toRemove = arrayDifference(oldClasses, newClasses); - toRemove = digestClassCounts(toRemove, -1); toAdd = digestClassCounts(toAdd, 1); - - if (toAdd.length === 0) { - $animate.removeClass(element, toRemove); - } else if (toRemove.length === 0) { + toRemove = digestClassCounts(toRemove, -1); + if (toAdd && toAdd.length) { $animate.addClass(element, toAdd); - } else { - $animate.setClass(element, toAdd, toRemove); + } + if (toRemove && toRemove.length) { + $animate.removeClass(element, toRemove); } } @@ -18684,10 +21100,16 @@ var ngCloakDirective = ngDirective({ * * @element ANY * @scope - * @param {expression} ngController Name of a globally accessible constructor function or an - * {@link guide/expression expression} that on the current scope evaluates to a - * constructor function. The controller instance can be published into a scope property - * by specifying `as propertyName`. + * @param {expression} ngController Name of a constructor function registered with the current + * {@link ng.$controllerProvider $controllerProvider} or an {@link guide/expression expression} + * that on the current scope evaluates to a constructor function. + * + * The controller instance can be published into a scope property by specifying + * `ng-controller="as propertyName"`. + * + * If the current `$controllerProvider` is configured to use globals (via + * {@link ng.$controllerProvider#allowGlobals `$controllerProvider.allowGlobals()` }), this may + * also be the name of a globally accessible constructor function (not recommended). * * @example * Here is a simple form for editing user contact information. Adding, removing, clearing, and @@ -18882,6 +21304,7 @@ var ngCloakDirective = ngDirective({ */ var ngControllerDirective = [function() { return { + restrict: 'A', scope: true, controller: '@', priority: 500 @@ -18899,8 +21322,10 @@ var ngControllerDirective = [function() { * This is necessary when developing things like Google Chrome Extensions. * * CSP forbids apps to use `eval` or `Function(string)` generated functions (among other things). - * For us to be compatible, we just need to implement the "getterFn" in $parse without violating - * any of these restrictions. + * For Angular to be CSP compatible there are only two things that we need to do differently: + * + * - don't use `Function` constructor to generate optimized value getters + * - don't inject custom stylesheet into the document * * AngularJS uses `Function(string)` generated functions as a speed optimization. Applying the `ngCsp` * directive will cause Angular to use CSP compatibility mode. When this mode is on AngularJS will @@ -18911,7 +21336,18 @@ var ngControllerDirective = [function() { * includes some CSS rules (e.g. {@link ng.directive:ngCloak ngCloak}). * To make those directives work in CSP mode, include the `angular-csp.css` manually. * - * In order to use this feature put the `ngCsp` directive on the root element of the application. + * Angular tries to autodetect if CSP is active and automatically turn on the CSP-safe mode. This + * autodetection however triggers a CSP error to be logged in the console: + * + * ``` + * Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of + * script in the following Content Security Policy directive: "default-src 'self'". Note that + * 'script-src' was not explicitly set, so 'default-src' is used as a fallback. + * ``` + * + * This error is harmless but annoying. To prevent the error from showing up, put the `ngCsp` + * directive on the root element of the application or on the `angular.js` script tag, whichever + * appears first in the html document. * * *Note: This directive is only available in the `ng-csp` and `data-ng-csp` attribute form.* * @@ -18926,9 +21362,9 @@ var ngControllerDirective = [function() { ``` */ -// ngCsp is not implemented as a proper directive any more, because we need it be processed while we bootstrap -// the system (before $parse is instantiated), for this reason we just have a csp() fn that looks for ng-csp attribute -// anywhere in the current doc +// ngCsp is not implemented as a proper directive any more, because we need it be processed while we +// bootstrap the system (before $parse is instantiated), for this reason we just have +// the csp.isActive() fn that looks for ng-csp attribute anywhere in the current doc /** * @ngdoc directive @@ -18949,7 +21385,9 @@ var ngControllerDirective = [function() { - count: {{count}} + + count: {{count}} + it('should check ng-click', function() { @@ -18967,19 +21405,34 @@ var ngControllerDirective = [function() { * Events that are handled via these handler are always configured not to propagate further. */ var ngEventDirectives = {}; + +// For events that might fire synchronously during DOM manipulation +// we need to execute their event handlers asynchronously using $evalAsync, +// so that they are not executed in an inconsistent state. +var forceAsyncEvents = { + 'blur': true, + 'focus': true +}; forEach( 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '), function(name) { var directiveName = directiveNormalize('ng-' + name); - ngEventDirectives[directiveName] = ['$parse', function($parse) { + ngEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse, $rootScope) { return { + restrict: 'A', compile: function($element, attr) { var fn = $parse(attr[directiveName]); return function ngEventHandler(scope, element) { - element.on(lowercase(name), function(event) { - scope.$apply(function() { + var eventName = lowercase(name); + element.on(eventName, function(event) { + var callback = function() { fn(scope, {$event:event}); - }); + }; + if (forceAsyncEvents[eventName] && $rootScope.$$phase) { + scope.$evalAsync(callback); + } else { + scope.$apply(callback); + } }); }; } @@ -19237,6 +21690,13 @@ forEach( * server and reloading the current page), but only if the form does not contain `action`, * `data-action`, or `x-action` attributes. * + *
+ * **Warning:** Be careful not to cause "double-submission" by using both the `ngClick` and + * `ngSubmit` handlers together. See the + * {@link form#submitting-a-form-and-preventing-the-default-action `form` directive documentation} + * for a detailed discussion of when `ngSubmit` may be triggered. + *
+ * * @element form * @priority 0 * @param {expression} ngSubmit {@link guide/expression Expression} to eval. @@ -19289,6 +21749,10 @@ forEach( * @description * Specify custom behavior on focus event. * + * Note: As the `focus` event is executed synchronously when calling `input.focus()` + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. + * * @element window, input, select, textarea, a * @priority 0 * @param {expression} ngFocus {@link guide/expression Expression} to evaluate upon @@ -19305,6 +21769,14 @@ forEach( * @description * Specify custom behavior on blur event. * + * A [blur event](https://developer.mozilla.org/en-US/docs/Web/Events/blur) fires when + * an element has lost focus. + * + * Note: As the `blur` event is executed synchronously also during DOM manipulations + * (e.g. removing a focussed input), + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. + * * @element window, input, select, textarea, a * @priority 0 * @param {expression} ngBlur {@link guide/expression Expression} to evaluate upon @@ -19455,6 +21927,7 @@ forEach( */ var ngIfDirective = ['$animate', function($animate) { return { + multiElement: true, transclude: 'element', priority: 600, terminal: true, @@ -19464,10 +21937,10 @@ var ngIfDirective = ['$animate', function($animate) { var block, childScope, previousElements; $scope.$watch($attr.ngIf, function ngIfWatchAction(value) { - if (toBoolean(value)) { + if (value) { if (!childScope) { - childScope = $scope.$new(); - $transclude(childScope, function (clone) { + $transclude(function (clone, newScope) { + childScope = newScope; clone[clone.length++] = document.createComment(' end ngIf: ' + $attr.ngIf + ' '); // 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 @@ -19488,8 +21961,8 @@ var ngIfDirective = ['$animate', function($animate) { childScope = null; } if(block) { - previousElements = getBlockElements(block.clone); - $animate.leave(previousElements, function() { + previousElements = getBlockNodes(block.clone); + $animate.leave(previousElements).then(function() { previousElements = null; }); block = null; @@ -19660,8 +22133,17 @@ var ngIfDirective = ['$animate', function($animate) { * @description * Emitted every time the ngInclude content is reloaded. */ -var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate', '$sce', - function($http, $templateCache, $anchorScroll, $animate, $sce) { + + +/** + * @ngdoc event + * @name ngInclude#$includeContentError + * @eventType emit on the scope ngInclude was declared in + * @description + * Emitted when a template HTTP request yields an erronous response (status < 200 || status > 299) + */ +var ngIncludeDirective = ['$templateRequest', '$anchorScroll', '$animate', '$sce', + function($templateRequest, $anchorScroll, $animate, $sce) { return { restrict: 'ECA', priority: 400, @@ -19689,7 +22171,7 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' currentScope = null; } if(currentElement) { - $animate.leave(currentElement, function() { + $animate.leave(currentElement).then(function() { previousElement = null; }); previousElement = currentElement; @@ -19706,7 +22188,9 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' var thisChangeId = ++changeCounter; if (src) { - $http.get(src, {cache: $templateCache}).success(function(response) { + //set the 2nd param to true to ignore the template request error so that the inner + //contents and scope can be cleaned up. + $templateRequest(src, true).then(function(response) { if (thisChangeId !== changeCounter) return; var newScope = scope.$new(); ctrl.template = response; @@ -19719,7 +22203,7 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' // directives to non existing elements. var clone = $transclude(newScope, function(clone) { cleanupLastIncludeContent(); - $animate.enter(clone, null, $element, afterAnimation); + $animate.enter(clone, null, $element).then(afterAnimation); }); currentScope = newScope; @@ -19727,8 +22211,11 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' currentScope.$emit('$includeContentLoaded'); scope.$eval(onloadExp); - }).error(function() { - if (thisChangeId === changeCounter) cleanupLastIncludeContent(); + }, function() { + if (thisChangeId === changeCounter) { + cleanupLastIncludeContent(); + scope.$emit('$includeContentError'); + } }); scope.$emit('$includeContentRequested'); } else { @@ -19753,6 +22240,18 @@ var ngIncludeFillContentDirective = ['$compile', priority: -400, require: 'ngInclude', link: function(scope, $element, $attr, ctrl) { + if (/SVG/.test($element[0].toString())) { + // WebKit: https://bugs.webkit.org/show_bug.cgi?id=135698 --- SVG elements do not + // support innerHTML, so detect this here and try to generate the contents + // specially. + $element.empty(); + $compile(jqLiteBuildFragment(ctrl.template, document).childNodes)(scope, + function namespaceAdaptedClone(clone) { + $element.append(clone); + }, undefined, undefined, $element); + return; + } + $element.html(ctrl.template); $compile($element.contents())(scope); } @@ -20065,7 +22564,7 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp //if explicit number rule such as 1, 2, 3... is defined, just use it. Otherwise, //check it against pluralization rules in $locale service if (!(value in whens)) value = $locale.pluralCat(value - offset); - return whensExpFns[value](scope, element, true); + return whensExpFns[value](scope); } else { return ''; } @@ -20176,6 +22675,13 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp * For example: `item in items` is equivalent to `item in items track by $id(item)`. This implies that the DOM elements * will be associated by item identity in the array. * + * * `variable in expression as alias_expression` – You can also provide an optional alias expression which will then store the + * intermediate results of the repeater after the filters have been applied. Typically this is used to render a special message + * when a filter is active on the repeater, but the filtered result set is empty. + * + * For example: `item in items | filter:x as results` will store the fragment of the repeated items as `results`, but only after + * the items have been processed through the filter. + * * For example: `item in items track by $id(item)`. A built in `$id()` function can be used to assign a unique * `$$hashKey` property to each item in the array. This property is then used as a key to associated DOM elements * with the corresponding item in the array by identity. Moving the same object in array would move the DOM @@ -20208,9 +22714,12 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp I have {{friends.length}} friends. They are:
    -
  • +
  • [{{$index + 1}}] {{friend.name}} who is {{friend.age}} years old.
  • +
  • + No results found... +
@@ -20277,29 +22786,84 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { var NG_REMOVED = '$$NG_REMOVED'; var ngRepeatMinErr = 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, - link: function($scope, $element, $attr, ctrl, $transclude){ - var expression = $attr.ngRepeat; - var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/), - trackByExp, trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn, - lhs, rhs, valueIdentifier, keyIdentifier, - hashFnLocals = {$id: hashKey}; + compile: function ngRepeatCompile($element, $attr) { + var expression = $attr.ngRepeat; + var ngRepeatEndComment = document.createComment(' end ngRepeat: ' + expression + ' '); - if (!match) { - throw ngRepeatMinErr('iexp', "Expected expression in form of '_item_ in _collection_[ track by _id_]' but got '{0}'.", + 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); - } + } - lhs = match[1]; - rhs = match[2]; - trackByExp = match[3]; + var lhs = match[1]; + var rhs = match[2]; + var aliasAs = match[3]; + var trackByExp = match[4]; - if (trackByExp) { - trackByExpGetter = $parse(trackByExp); + match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); + + 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)$/.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; @@ -20307,48 +22871,39 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { hashFnLocals.$index = index; return trackByExpGetter($scope, hashFnLocals); }; - } else { - trackByIdArrayFn = function(key, value) { - return hashKey(value); - }; - trackByIdObjFn = function(key) { - return key; - }; } - match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); - if (!match) { - throw ngRepeatMinErr('iidexp', "'_item_' in '_item_ in _collection_' should be an identifier or '(_key_, _value_)' expression, but got '{0}'.", - lhs); - } - valueIdentifier = match[3] || match[1]; - keyIdentifier = match[2]; - // 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 - var lastBlockMap = {}; + // + // We are using no-proto object so that we don't need to guard against inherited props via + // hasOwnProperty. + var lastBlockMap = createMap(); //watch props - $scope.$watchCollection(rhs, function ngRepeatAction(collection){ + $scope.$watchCollection(rhs, function ngRepeatAction(collection) { var index, length, - previousNode = $element[0], // current position of the node + 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 = {}, - arrayLength, - childScope, + nextBlockMap = createMap(), + collectionLength, key, value, // key/value of iteration trackById, trackByIdFn, collectionKeys, block, // last object information {scope, element, id} - nextBlockOrder = [], + nextBlockOrder, elementsToRemove; + if (aliasAs) { + $scope[aliasAs] = collection; + } if (isArrayLike(collection)) { collectionKeys = collection; @@ -20357,118 +22912,106 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { trackByIdFn = trackByIdExpFn || trackByIdObjFn; // if object, extract keys, sort them and use to determine order of iteration over obj props collectionKeys = []; - for (key in collection) { - if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { - collectionKeys.push(key); + for (var itemKey in collection) { + if (collection.hasOwnProperty(itemKey) && itemKey.charAt(0) != '$') { + collectionKeys.push(itemKey); } } collectionKeys.sort(); } - arrayLength = collectionKeys.length; + collectionLength = collectionKeys.length; + nextBlockOrder = new Array(collectionLength); // locate existing items - length = nextBlockOrder.length = collectionKeys.length; - for(index = 0; index < length; index++) { - key = (collection === collectionKeys) ? index : collectionKeys[index]; - value = collection[key]; - trackById = trackByIdFn(key, value, index); - assertNotHasOwnProperty(trackById, '`track by` id'); - if(lastBlockMap.hasOwnProperty(trackById)) { - block = lastBlockMap[trackById]; - delete lastBlockMap[trackById]; - nextBlockMap[trackById] = block; - nextBlockOrder[index] = block; - } else if (nextBlockMap.hasOwnProperty(trackById)) { - // restore lastBlockMap - forEach(nextBlockOrder, function(block) { - if (block && block.scope) lastBlockMap[block.id] = block; - }); - // This is a duplicate and we need to throw an error - throw ngRepeatMinErr('dupes', "Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}", - expression, trackById); - } else { - // new never before seen block - nextBlockOrder[index] = { id: trackById }; - nextBlockMap[trackById] = false; - } - } - - // remove existing items - for (key in lastBlockMap) { - // lastBlockMap is our own object so we don't need to use special hasOwnPropertyFn - if (lastBlockMap.hasOwnProperty(key)) { - block = lastBlockMap[key]; - elementsToRemove = getBlockElements(block.clone); - $animate.leave(elementsToRemove); - forEach(elementsToRemove, function(element) { element[NG_REMOVED] = true; }); - block.scope.$destroy(); + 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 + forEach(nextBlockOrder, 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, toJson(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, length = collectionKeys.length; index < length; index++) { + for (index = 0; index < collectionLength; index++) { key = (collection === collectionKeys) ? index : collectionKeys[index]; value = collection[key]; block = nextBlockOrder[index]; - if (nextBlockOrder[index - 1]) previousNode = getBlockEnd(nextBlockOrder[index - 1]); if (block.scope) { // if we have already seen this object, then we need to reuse the // associated scope/element - childScope = block.scope; nextNode = previousNode; + + // skip nodes that are already pending removal via leave animation do { nextNode = nextNode.nextSibling; - } while(nextNode && nextNode[NG_REMOVED]); + } while (nextNode && nextNode[NG_REMOVED]); if (getBlockStart(block) != nextNode) { // existing item which got moved - $animate.move(getBlockElements(block.clone), null, jqLite(previousNode)); + $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 - childScope = $scope.$new(); - } + $transclude(function ngRepeatTransclude(clone, scope) { + block.scope = scope; + // http://jsperf.com/clone-vs-createcomment + var endNode = ngRepeatEndComment.cloneNode(false); + clone[clone.length++] = endNode; - childScope[valueIdentifier] = value; - if (keyIdentifier) childScope[keyIdentifier] = key; - childScope.$index = index; - childScope.$first = (index === 0); - childScope.$last = (index === (arrayLength - 1)); - childScope.$middle = !(childScope.$first || childScope.$last); - // jshint bitwise: false - childScope.$odd = !(childScope.$even = (index&1) === 0); - // jshint bitwise: true - - if (!block.scope) { - $transclude(childScope, function(clone) { - clone[clone.length++] = document.createComment(' end ngRepeat: ' + expression + ' '); + // TODO(perf): support naked previousNode in `enter` to avoid creation of jqLite wrapper? $animate.enter(clone, null, jqLite(previousNode)); - previousNode = clone; - block.scope = childScope; + 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; }); + }; } }; - - function getBlockStart(block) { - return block.clone[0]; - } - - function getBlockEnd(block) { - return block.clone[block.clone.length - 1]; - } }]; /** @@ -20490,15 +23033,10 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { *
* ``` * - * When the ngShow expression evaluates to false then the ng-hide CSS class is added to the class attribute - * on the element causing it to become hidden. When true, the ng-hide CSS class is removed + * When the ngShow expression evaluates to a falsy value then the ng-hide CSS class is added to the class + * attribute on the element causing it to become hidden. When truthy, the ng-hide CSS class is removed * from the element causing the element not to appear hidden. * - *
- * **Note:** Here is a list of values that ngShow will consider as a falsy value (case insensitive):
- * "f" / "0" / "false" / "no" / "n" / "[]" - *
- * * ## Why is !important used? * * You may be wondering why !important is used for the .ng-hide CSS class. This is because the `.ng-hide` selector @@ -20518,7 +23056,7 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { * * ```css * .ng-hide { - * //this is just another form of hiding an element + * /* this is just another form of hiding an element */ * display:block!important; * position:absolute; * top:-9999px; @@ -20540,7 +23078,15 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { * //a working example can be found at the bottom of this page * // * .my-element.ng-hide-add, .my-element.ng-hide-remove { - * transition:0.5s linear all; + * /* this is required as of 1.3x to properly + * apply all styling in a show/hide animation */ + * transition:0s linear all; + * } + * + * .my-element.ng-hide-add-active, + * .my-element.ng-hide-remove-active { + * /* the transition is defined in the active class */ + * transition:1s linear all; * } * * .my-element.ng-hide-add { ... } @@ -20549,7 +23095,7 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { * .my-element.ng-hide-remove.ng-hide-remove-active { ... } * ``` * - * Keep in mind that, as of AngularJS version 1.2.17 (and 1.3.0-beta.11), there is no need to change the display + * Keep in mind that, as of AngularJS version 1.3.0-beta.11, there is no need to change the display * property to block during animation states--ngAnimate will handle the style toggling automatically for you. * * @animations @@ -20582,8 +23128,6 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { .animate-show { - -webkit-transition:all linear 0.5s; - transition:all linear 0.5s; line-height:20px; opacity:1; padding:10px; @@ -20591,6 +23135,12 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { background:white; } + .animate-show.ng-hide-add.ng-hide-add-active, + .animate-show.ng-hide-remove.ng-hide-remove-active { + -webkit-transition:all linear 0.5s; + transition:all linear 0.5s; + } + .animate-show.ng-hide { line-height:0; opacity:0; @@ -20620,10 +23170,14 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { */ var ngShowDirective = ['$animate', function($animate) { - return function(scope, element, attr) { - scope.$watch(attr.ngShow, function ngShowWatchAction(value){ - $animate[toBoolean(value) ? 'removeClass' : 'addClass'](element, 'ng-hide'); - }); + return { + restrict: 'A', + multiElement: true, + link: function(scope, element, attr) { + scope.$watch(attr.ngShow, function ngShowWatchAction(value){ + $animate[value ? 'removeClass' : 'addClass'](element, 'ng-hide'); + }); + } }; }]; @@ -20647,15 +23201,10 @@ var ngShowDirective = ['$animate', function($animate) { *
* ``` * - * When the ngHide expression evaluates to true then the .ng-hide CSS class is added to the class attribute - * on the element causing it to become hidden. When false, the ng-hide CSS class is removed + * When the ngHide expression evaluates to a truthy value then the .ng-hide CSS class is added to the class + * attribute on the element causing it to become hidden. When falsy, the ng-hide CSS class is removed * from the element causing the element not to appear hidden. * - *
- * **Note:** Here is a list of values that ngHide will consider as a falsy value (case insensitive):
- * "f" / "0" / "false" / "no" / "n" / "[]" - *
- * * ## Why is !important used? * * You may be wondering why !important is used for the .ng-hide CSS class. This is because the `.ng-hide` selector @@ -20675,7 +23224,7 @@ var ngShowDirective = ['$animate', function($animate) { * * ```css * .ng-hide { - * //this is just another form of hiding an element + * /* this is just another form of hiding an element */ * display:block!important; * position:absolute; * top:-9999px; @@ -20705,7 +23254,7 @@ var ngShowDirective = ['$animate', function($animate) { * .my-element.ng-hide-remove.ng-hide-remove-active { ... } * ``` * - * Keep in mind that, as of AngularJS version 1.2.17 (and 1.3.0-beta.11), there is no need to change the display + * Keep in mind that, as of AngularJS version 1.3.0-beta.11, there is no need to change the display * property to block during animation states--ngAnimate will handle the style toggling automatically for you. * * @animations @@ -20776,10 +23325,14 @@ var ngShowDirective = ['$animate', function($animate) { */ var ngHideDirective = ['$animate', function($animate) { - return function(scope, element, attr) { - scope.$watch(attr.ngHide, function ngHideWatchAction(value){ - $animate[toBoolean(value) ? 'addClass' : 'removeClass'](element, 'ng-hide'); - }); + return { + restrict: 'A', + multiElement: true, + link: function(scope, element, attr) { + scope.$watch(attr.ngHide, function ngHideWatchAction(value){ + $animate[value ? 'addClass' : 'removeClass'](element, 'ng-hide'); + }); + } }; }]; @@ -20880,7 +23433,7 @@ var ngStyleDirective = ngDirective(function(scope, element, attr) { * * * @scope - * @priority 800 + * @priority 1200 * @param {*} ngSwitch|on expression to match against ng-switch-when. * On child elements add: * @@ -20979,37 +23532,39 @@ var ngSwitchDirective = ['$animate', function($animate) { var watchExpr = attr.ngSwitch || attr.on, selectedTranscludes = [], selectedElements = [], - previousElements = [], + previousLeaveAnimations = [], selectedScopes = []; + var spliceFactory = function(array, index) { + return function() { array.splice(index, 1); }; + }; + scope.$watch(watchExpr, function ngSwitchWatchAction(value) { var i, ii; - for (i = 0, ii = previousElements.length; i < ii; ++i) { - previousElements[i].remove(); + for (i = 0, ii = previousLeaveAnimations.length; i < ii; ++i) { + $animate.cancel(previousLeaveAnimations[i]); } - previousElements.length = 0; + previousLeaveAnimations.length = 0; for (i = 0, ii = selectedScopes.length; i < ii; ++i) { - var selected = selectedElements[i]; + var selected = getBlockNodes(selectedElements[i].clone); selectedScopes[i].$destroy(); - previousElements[i] = selected; - $animate.leave(selected, function() { - previousElements.splice(i, 1); - }); + var promise = previousLeaveAnimations[i] = $animate.leave(selected); + promise.then(spliceFactory(previousLeaveAnimations, i)); } selectedElements.length = 0; selectedScopes.length = 0; if ((selectedTranscludes = ngSwitchController.cases['!' + value] || ngSwitchController.cases['?'])) { - scope.$eval(attr.change); forEach(selectedTranscludes, function(selectedTransclude) { - var selectedScope = scope.$new(); - selectedScopes.push(selectedScope); - selectedTransclude.transclude(selectedScope, function(caseElement) { + selectedTransclude.transclude(function(caseElement, selectedScope) { + selectedScopes.push(selectedScope); var anchor = selectedTransclude.element; + caseElement[caseElement.length++] = document.createComment(' end ngSwitchWhen: '); + var block = { clone: caseElement }; - selectedElements.push(caseElement); + selectedElements.push(block); $animate.enter(caseElement, anchor.parent(), anchor); }); }); @@ -21021,8 +23576,9 @@ var ngSwitchDirective = ['$animate', function($animate) { var ngSwitchWhenDirective = ngDirective({ transclude: 'element', - priority: 800, + priority: 1200, require: '^ngSwitch', + multiElement: true, link: function(scope, element, attrs, ctrl, $transclude) { ctrl.cases['!' + attrs.ngSwitchWhen] = (ctrl.cases['!' + attrs.ngSwitchWhen] || []); ctrl.cases['!' + attrs.ngSwitchWhen].push({ transclude: $transclude, element: element }); @@ -21031,8 +23587,9 @@ var ngSwitchWhenDirective = ngDirective({ var ngSwitchDefaultDirective = ngDirective({ transclude: 'element', - priority: 800, + priority: 1200, require: '^ngSwitch', + multiElement: true, link: function(scope, element, attr, ctrl, $transclude) { ctrl.cases['?'] = (ctrl.cases['?'] || []); ctrl.cases['?'].push({ transclude: $transclude, element: element }); @@ -21042,7 +23599,7 @@ var ngSwitchDefaultDirective = ngDirective({ /** * @ngdoc directive * @name ngTransclude - * @restrict AC + * @restrict EAC * * @description * Directive that marks the insertion point for the transcluded DOM of the nearest parent directive that uses transclusion. @@ -21063,7 +23620,7 @@ var ngSwitchDefaultDirective = ngDirective({ scope: { title:'@' }, template: '
' + '
{{title}}
' + - '
' + + '' + '
' }; }) @@ -21094,6 +23651,7 @@ var ngSwitchDefaultDirective = ngDirective({ * */ var ngTranscludeDirective = ngDirective({ + restrict: 'EAC', link: function($scope, $element, $attrs, controller, $transclude) { if (!$transclude) { throw minErr('ngTransclude')('orphan', @@ -21274,7 +23832,7 @@ var ngOptionsMinErr = minErr('ngOptions'); Select bogus.

- Currently selected: {{ {selected_color:myColor} }} + Currently selected: {{ {selected_color:myColor} }}
@@ -21294,7 +23852,11 @@ var ngOptionsMinErr = minErr('ngOptions'); */ -var ngOptionsDirective = valueFn({ terminal: true }); +var ngOptionsDirective = valueFn({ + restrict: 'A', + terminal: true +}); + // jshint maxlen: false var selectDirective = ['$compile', '$parse', function($compile, $parse) { //000011111111110000000000022222222220000000000000000000003333333333000000000000004444444444444440000000005555555555555550000000666666666666666000000000000000777777777700000000000000000008888888888 @@ -21323,7 +23885,7 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) { }; - self.addOption = function(value) { + self.addOption = function(value, element) { assertNotHasOwnProperty(value, '"option value"'); optionsMap[value] = true; @@ -21331,6 +23893,12 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) { $element.val(value); if (unknownOption.parent()) unknownOption.remove(); } + // Workaround for https://code.google.com/p/chromium/issues/detail?id=381459 + // Adding an
+ + {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }} + + {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }}
+
{{ room.lastMsg.inviter | mUserDisplayName: room.room_id }} invited you @@ -61,7 +66,7 @@
- +
@@ -71,6 +76,14 @@ +
+ {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ room.lastMsg.content.topic }} +
+ +
+ {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ room.lastMsg.content.name }} +
+
Call diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index c8ca771b2..45dfff95c 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -15,8 +15,8 @@ limitations under the License. */ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) -.controller('RoomController', ['$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall', - function($scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) { +.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall', + function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) { 'use strict'; var MESSAGES_PER_PAGINATION = 30; var THUMBNAIL_SIZE = 320; @@ -27,12 +27,12 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $scope.state = { user_id: matrixService.config().user_id, - events_from: "END", // when to start the event stream from. - earliest_token: "END", // stores how far back we've paginated. + permission_denied: undefined, // If defined, this string contains the reason why the user cannot join the room first_pagination: true, // this is toggled off when the first pagination is done - can_paginate: true, // this is toggled off when we run out of items + can_paginate: false, // this is toggled off when we are not ready yet to paginate or when we run out of items paginating: false, // used to avoid concurrent pagination requests pulling in dup contents - stream_failure: undefined // the response when the stream fails + stream_failure: undefined, // the response when the stream fails + waiting_for_joined_event: false // true when the join request is pending. Back to false once the corresponding m.room.member event is received }; $scope.members = {}; $scope.autoCompleting = false; @@ -42,6 +42,85 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $scope.imageURLToSend = ""; $scope.userIDToInvite = ""; + + // vars and functions for updating the name + $scope.name = { + isEditing: false, + newNameText: "", + editName: function() { + if ($scope.name.isEditing) { + console.log("Warning: Already editing name."); + return; + }; + + // Use the filter applied in html to set the input value + $scope.name.newNameText = $filter('mRoomName')($scope.room_id); + + // Force focus to the input + $timeout(function() { + angular.element('.roomNameInput').focus(); + }, 0); + + $scope.name.isEditing = true; + }, + updateName: function() { + console.log("Updating name to "+$scope.name.newNameText); + matrixService.setName($scope.room_id, $scope.name.newNameText).then( + function() { + }, + function(error) { + $scope.feedback = "Request failed: " + error.data.error; + } + ); + + $scope.name.isEditing = false; + }, + cancelEdit: function() { + $scope.name.isEditing = false; + } + }; + + // vars and functions for updating the topic + $scope.topic = { + isEditing: false, + newTopicText: "", + editTopic: function() { + if ($scope.topic.isEditing) { + console.log("Warning: Already editing topic."); + return; + } + var topicEvent = $rootScope.events.rooms[$scope.room_id]['m.room.topic']; + if (topicEvent) { + $scope.topic.newTopicText = topicEvent.content.topic; + } + else { + $scope.topic.newTopicText = ""; + } + + // Force focus to the input + $timeout(function() { + angular.element('.roomTopicInput').focus(); + }, 0); + + $scope.topic.isEditing = true; + }, + updateTopic: function() { + console.log("Updating topic to "+$scope.topic.newTopicText); + matrixService.setTopic($scope.room_id, $scope.topic.newTopicText).then( + function() { + }, + function(error) { + $scope.feedback = "Request failed: " + error.data.error; + } + ); + + $scope.topic.isEditing = false; + }, + cancelEdit: function() { + $scope.topic.isEditing = false; + } + }; + var scrollToBottom = function(force) { console.log("Scrolling to bottom"); @@ -81,8 +160,37 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { if (isLive) { - scrollToBottom(); - updateMemberList(event); + if ($scope.state.waiting_for_joined_event) { + // The user has successfully joined the room, we can getting data for this room + $scope.state.waiting_for_joined_event = false; + onInit3(); + } + else if (event.state_key === $scope.state.user_id && "invite" !== event.membership && "join" !== event.membership) { + var user; + + if ($scope.members[event.user_id]) { + user = $scope.members[event.user_id].displayname; + } + if (user) { + user = user + " (" + event.user_id + ")"; + } + else { + user = event.user_id; + } + + + if ("ban" === event.membership) { + $scope.state.permission_denied = "You have been banned by " + user; + } + else { + $scope.state.permission_denied = "You have been kicked by " + user; + } + + } + else { + scrollToBottom(); + updateMemberList(event); + } } }); @@ -119,12 +227,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) else { $scope.state.paginating = true; } - // console.log("paginateBackMessages from " + $scope.state.earliest_token + " for " + numItems); + + console.log("paginateBackMessages from " + $rootScope.events.rooms[$scope.room_id].pagination.earliest_token + " for " + numItems); var originalTopRow = $("#messageTable>tbody>tr:first")[0]; - matrixService.paginateBackMessages($scope.room_id, $scope.state.earliest_token, numItems).then( + + // Paginate events from the point in cache + matrixService.paginateBackMessages($scope.room_id, $rootScope.events.rooms[$scope.room_id].pagination.earliest_token, numItems).then( function(response) { - eventHandlerService.handleEvents(response.data.chunk, false); - $scope.state.earliest_token = response.data.end; + + eventHandlerService.handleRoomMessages($scope.room_id, response.data, false); if (response.data.chunk.length < MESSAGES_PER_PAGINATION) { // no more messages to paginate. this currently never gets turned true again, as we never // expire paginated contents in the current implementation. @@ -419,7 +530,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) var powerLevel = 50; // default power level for op if (matches) { var user_id = matches[1]; - if (matches.length === 4) { + if (matches.length === 4 && undefined !== matches[3]) { powerLevel = parseInt(matches[3]); } if (powerLevel !== NaN) { @@ -472,8 +583,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) room_id: $scope.room_id, type: "m.room.message", user_id: $scope.state.user_id, - // FIXME: re-enable echo_msg_state when we have a nice way to turn the field off again - // echo_msg_state: "messagePending" // Add custom field to indicate the state of this fake message to HTML + echo_msg_state: "messagePending" // Add custom field to indicate the state of this fake message to HTML }; $scope.textInput = ""; @@ -482,26 +592,22 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) } if (promise) { + // Reset previous feedback + $scope.feedback = ""; + promise.then( - function() { + function(response) { console.log("Request successfully sent"); - if (!echo) { - $scope.textInput = ""; - } -/* - if (echoMessage) { - // Remove the fake echo message from the room messages - // It will be replaced by the one acknowledged by the server - // ...except this causes a nasty flicker. So don't swap messages for now. --matthew - // var index = $rootScope.events.rooms[$scope.room_id].messages.indexOf(echoMessage); - // if (index > -1) { - // $rootScope.events.rooms[$scope.room_id].messages.splice(index, 1); - // } + + if (echo) { + // Mark this fake message event with its allocated event_id + // When the true message event will come from the events stream (in handleMessage), + // we will be able to replace the fake one by the true one + echoMessage.event_id = response.data.event_id; } else { $scope.textInput = ""; - } -*/ + } }, function(error) { $scope.feedback = "Request failed: " + error.data.error; @@ -596,14 +702,18 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // Do we to join the room before starting? if (needsToJoin) { + $scope.state.waiting_for_joined_event = true; matrixService.join($scope.room_id).then( function() { + // onInit3 will be called once the joined m.room.member event is received from the events stream + // This avoids to get the joined information twice in parallel: + // - one from the events stream + // - one from the pagination because the pagination window covers this event ts console.log("Joined room "+$scope.room_id); - onInit3(); }, function(reason) { console.log("Can't join room: " + JSON.stringify(reason)); - $scope.feedback = "You do not have permission to join this room"; + $scope.state.permission_denied = "You do not have permission to join this room"; }); } else { @@ -615,9 +725,6 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) var onInit3 = function() { console.log("onInit3"); - - // TODO: We should be able to keep them - eventHandlerService.resetRoomMessages($scope.room_id); // Make recents highlight the current room $scope.recentsSelectedRoomID = $scope.room_id; @@ -635,21 +742,24 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // Arm list timing update timer updateMemberListPresenceAge(); + + // Start pagination + $scope.state.can_paginate = true; + paginate(MESSAGES_PER_PAGINATION); }, function(error) { $scope.feedback = "Failed get member list: " + error.data.error; } ); - - paginate(MESSAGES_PER_PAGINATION); }; - $scope.inviteUser = function(user_id) { + $scope.inviteUser = function() { - matrixService.invite($scope.room_id, user_id).then( + matrixService.invite($scope.room_id, $scope.userIDToInvite).then( function() { console.log("Invited."); - $scope.feedback = "Invite sent successfully"; + $scope.feedback = "Invite successfully sent to " + $scope.userIDToInvite; + $scope.userIDToInvite = ""; }, function(reason) { $scope.feedback = "Failure: " + reason; @@ -710,7 +820,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) var call = new MatrixCall($scope.room_id); call.onError = $rootScope.onCallError; call.onHangup = $rootScope.onCallHangup; - call.placeCall(); + call.placeCall({audio: true, video: false}); + $rootScope.currentCall = call; + }; + + $scope.startVideoCall = function() { + var call = new MatrixCall($scope.room_id); + call.onError = $rootScope.onCallError; + call.onHangup = $rootScope.onCallHangup; + call.placeCall({audio: true, video: true}); $rootScope.currentCall = call; }; diff --git a/webclient/room/room.html b/webclient/room/room.html index 5bd2cc92d..9d617eadd 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -2,8 +2,31 @@
[matrix] -
- {{ room_id | mRoomName }} +
+ +
+
+ {{ room_id | mRoomName }} +
+
+ +
+
+ +
+ +
+
+ {{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}} +
+
+ +
+
+
@@ -14,14 +37,14 @@
-
+
{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}
@@ -33,7 +56,7 @@
-
+
-
+
{{ members[msg.state_key].displayname || msg.state_key }} joined @@ -76,10 +99,18 @@ - + + + + Outgoing Call + Incoming Call +
@@ -89,6 +120,15 @@ ng-click="$parent.fullScreenImageURL = msg.content.url"/>
+ + + {{ members[msg.user_id].displayname || msg.user_id }} changed the topic to: {{ msg.content.topic }} + + + + {{ members[msg.user_id].displayname || msg.user_id }} changed the room name to: {{ msg.content.name }} + +
@@ -98,6 +138,10 @@
+ +
+ {{ state.permission_denied }} +
@@ -110,11 +154,13 @@ {{ state.user_id }}
- +