1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview This is a data model representin
7 */
8
9cr.define('cr.ui', function() {
10  /** @const */ var EventTarget = cr.EventTarget;
11  /** @const */ var Event = cr.Event;
12
13  /**
14   * A data model that wraps a simple array and supports sorting by storing
15   * initial indexes of elements for each position in sorted array.
16   * @param {!Array} array The underlying array.
17   * @constructor
18   * @extends {EventTarget}
19   */
20  function ArrayDataModel(array) {
21    this.array_ = array;
22    this.indexes_ = [];
23    this.compareFunctions_ = {};
24
25    for (var i = 0; i < array.length; i++) {
26      this.indexes_.push(i);
27    }
28  }
29
30  ArrayDataModel.prototype = {
31    __proto__: EventTarget.prototype,
32
33    /**
34     * The length of the data model.
35     * @type {number}
36     */
37    get length() {
38      return this.array_.length;
39    },
40
41    /**
42     * Returns the item at the given index.
43     * This implementation returns the item at the given index in the sorted
44     * array.
45     * @param {number} index The index of the element to get.
46     * @return {*} The element at the given index.
47     */
48    item: function(index) {
49      if (index >= 0 && index < this.length)
50        return this.array_[this.indexes_[index]];
51      return undefined;
52    },
53
54    /**
55     * Returns compare function set for given field.
56     * @param {string} field The field to get compare function for.
57     * @return {function(*, *): number} Compare function set for given field.
58     */
59    compareFunction: function(field) {
60      return this.compareFunctions_[field];
61    },
62
63    /**
64     * Sets compare function for given field.
65     * @param {string} field The field to set compare function.
66     * @param {function(*, *): number} Compare function to set for given field.
67     */
68    setCompareFunction: function(field, compareFunction) {
69      if (!this.compareFunctions_) {
70        this.compareFunctions_ = {};
71      }
72      this.compareFunctions_[field] = compareFunction;
73    },
74
75    /**
76     * Returns true if the field has a compare function.
77     * @param {string} field The field to check.
78     * @return {boolean} True if the field is sortable.
79     */
80    isSortable: function(field) {
81      return this.compareFunctions_ && field in this.compareFunctions_;
82    },
83
84    /**
85     * Returns current sort status.
86     * @return {!Object} Current sort status.
87     */
88    get sortStatus() {
89      if (this.sortStatus_) {
90        return this.createSortStatus(
91            this.sortStatus_.field, this.sortStatus_.direction);
92      } else {
93        return this.createSortStatus(null, null);
94      }
95    },
96
97    /**
98     * Returns the first matching item.
99     * @param {*} item The item to find.
100     * @param {number=} opt_fromIndex If provided, then the searching start at
101     *     the {@code opt_fromIndex}.
102     * @return {number} The index of the first found element or -1 if not found.
103     */
104    indexOf: function(item, opt_fromIndex) {
105      for (var i = opt_fromIndex || 0; i < this.indexes_.length; i++) {
106        if (item === this.item(i))
107          return i;
108      }
109      return -1;
110    },
111
112    /**
113     * Returns an array of elements in a selected range.
114     * @param {number=} opt_from The starting index of the selected range.
115     * @param {number=} opt_to The ending index of selected range.
116     * @return {Array} An array of elements in the selected range.
117     */
118    slice: function(opt_from, opt_to) {
119      var arr = this.array_;
120      return this.indexes_.slice(opt_from, opt_to).map(
121          function(index) { return arr[index] });
122    },
123
124    /**
125     * This removes and adds items to the model.
126     * This dispatches a splice event.
127     * This implementation runs sort after splice and creates permutation for
128     * the whole change.
129     * @param {number} index The index of the item to update.
130     * @param {number} deleteCount The number of items to remove.
131     * @param {...*} The items to add.
132     * @return {!Array} An array with the removed items.
133     */
134    splice: function(index, deleteCount, var_args) {
135      var addCount = arguments.length - 2;
136      var newIndexes = [];
137      var deletePermutation = [];
138      var deletedItems = [];
139      var newArray = [];
140      index = Math.min(index, this.indexes_.length);
141      deleteCount = Math.min(deleteCount, this.indexes_.length - index);
142      // Copy items before the insertion point.
143      for (var i = 0; i < index; i++) {
144        newIndexes.push(newArray.length);
145        deletePermutation.push(i);
146        newArray.push(this.array_[this.indexes_[i]]);
147      }
148      // Delete items.
149      for (; i < index + deleteCount; i++) {
150        deletePermutation.push(-1);
151        deletedItems.push(this.array_[this.indexes_[i]]);
152      }
153      // Insert new items instead deleted ones.
154      for (var j = 0; j < addCount; j++) {
155        newIndexes.push(newArray.length);
156        newArray.push(arguments[j + 2]);
157      }
158      // Copy items after the insertion point.
159      for (; i < this.indexes_.length; i++) {
160        newIndexes.push(newArray.length);
161        deletePermutation.push(i - deleteCount + addCount);
162        newArray.push(this.array_[this.indexes_[i]]);
163      }
164
165      this.indexes_ = newIndexes;
166
167      this.array_ = newArray;
168
169      // TODO(arv): Maybe unify splice and change events?
170      var spliceEvent = new Event('splice');
171      spliceEvent.removed = deletedItems;
172      spliceEvent.added = Array.prototype.slice.call(arguments, 2);
173
174      var status = this.sortStatus;
175      // if sortStatus.field is null, this restores original order.
176      var sortPermutation = this.doSort_(this.sortStatus.field,
177                                         this.sortStatus.direction);
178      if (sortPermutation) {
179        var splicePermutation = deletePermutation.map(function(element) {
180          return element != -1 ? sortPermutation[element] : -1;
181        });
182        this.dispatchPermutedEvent_(splicePermutation);
183        spliceEvent.index = sortPermutation[index];
184      } else {
185        this.dispatchPermutedEvent_(deletePermutation);
186        spliceEvent.index = index;
187      }
188
189      this.dispatchEvent(spliceEvent);
190
191      // If real sorting is needed, we should first call prepareSort (data may
192      // change), and then sort again.
193      // Still need to finish the sorting above (including events), so
194      // list will not go to inconsistent state.
195      if (status.field)
196        this.delayedSort_(status.field, status.direction);
197
198      return deletedItems;
199    },
200
201    /**
202     * Appends items to the end of the model.
203     *
204     * This dispatches a splice event.
205     *
206     * @param {...*} The items to append.
207     * @return {number} The new length of the model.
208     */
209    push: function(var_args) {
210      var args = Array.prototype.slice.call(arguments);
211      args.unshift(this.length, 0);
212      this.splice.apply(this, args);
213      return this.length;
214    },
215
216    /**
217     * Use this to update a given item in the array. This does not remove and
218     * reinsert a new item.
219     * This dispatches a change event.
220     * This runs sort after updating.
221     * @param {number} index The index of the item to update.
222     */
223    updateIndex: function(index) {
224      if (index < 0 || index >= this.length)
225        throw Error('Invalid index, ' + index);
226
227      // TODO(arv): Maybe unify splice and change events?
228      var e = new Event('change');
229      e.index = index;
230      this.dispatchEvent(e);
231
232      if (this.sortStatus.field) {
233        var status = this.sortStatus;
234        var sortPermutation = this.doSort_(this.sortStatus.field,
235                                           this.sortStatus.direction);
236        if (sortPermutation)
237          this.dispatchPermutedEvent_(sortPermutation);
238        // We should first call prepareSort (data may change), and then sort.
239        // Still need to finish the sorting above (including events), so
240        // list will not go to inconsistent state.
241        this.delayedSort_(status.field, status.direction);
242      }
243    },
244
245    /**
246     * Creates sort status with given field and direction.
247     * @param {string} field Sort field.
248     * @param {string} direction Sort direction.
249     * @return {!Object} Created sort status.
250     */
251    createSortStatus: function(field, direction) {
252      return {
253        field: field,
254        direction: direction
255      };
256    },
257
258    /**
259     * Called before a sort happens so that you may fetch additional data
260     * required for the sort.
261     *
262     * @param {string} field Sort field.
263     * @param {function()} callback The function to invoke when preparation
264     *     is complete.
265     */
266    prepareSort: function(field, callback) {
267      callback();
268    },
269
270    /**
271     * Sorts data model according to given field and direction and dispathes
272     * sorted event with delay. If no need to delay, use sort() instead.
273     * @param {string} field Sort field.
274     * @param {string} direction Sort direction.
275     * @private
276     */
277    delayedSort_: function(field, direction) {
278      var self = this;
279      setTimeout(function() {
280        // If the sort status has been changed, sorting has already done
281        // on the change event.
282        if (field == self.sortStatus.field &&
283            direction == self.sortStatus.direction) {
284          self.sort(field, direction);
285        }
286      }, 0);
287    },
288
289    /**
290     * Sorts data model according to given field and direction and dispathes
291     * sorted event.
292     * @param {string} field Sort field.
293     * @param {string} direction Sort direction.
294     */
295    sort: function(field, direction) {
296      var self = this;
297
298      this.prepareSort(field, function() {
299        var sortPermutation = self.doSort_(field, direction);
300        if (sortPermutation)
301          self.dispatchPermutedEvent_(sortPermutation);
302        self.dispatchSortEvent_();
303      });
304    },
305
306    /**
307     * Sorts data model according to given field and direction.
308     * @param {string} field Sort field.
309     * @param {string} direction Sort direction.
310     * @private
311     */
312    doSort_: function(field, direction) {
313      var compareFunction = this.sortFunction_(field, direction);
314      var positions = [];
315      for (var i = 0; i < this.length; i++) {
316        positions[this.indexes_[i]] = i;
317      }
318      this.indexes_.sort(compareFunction);
319      this.sortStatus_ = this.createSortStatus(field, direction);
320      var sortPermutation = [];
321      var changed = false;
322      for (var i = 0; i < this.length; i++) {
323        if (positions[this.indexes_[i]] != i)
324          changed = true;
325        sortPermutation[positions[this.indexes_[i]]] = i;
326      }
327      if (changed)
328        return sortPermutation;
329      return null;
330    },
331
332    dispatchSortEvent_: function() {
333      var e = new Event('sorted');
334      this.dispatchEvent(e);
335    },
336
337    dispatchPermutedEvent_: function(permutation) {
338      var e = new Event('permuted');
339      e.permutation = permutation;
340      e.newLength = this.length;
341      this.dispatchEvent(e);
342    },
343
344    /**
345     * Creates compare function for the field.
346     * Returns the function set as sortFunction for given field
347     * or default compare function
348     * @param {string} field Sort field.
349     * @param {function(*, *): number} Compare function.
350     * @private
351     */
352    createCompareFunction_: function(field) {
353      var compareFunction =
354          this.compareFunctions_ ? this.compareFunctions_[field] : null;
355      var defaultValuesCompareFunction = this.defaultValuesCompareFunction;
356      if (compareFunction) {
357        return compareFunction;
358      } else {
359        return function(a, b) {
360          return defaultValuesCompareFunction.call(null, a[field], b[field]);
361        }
362      }
363      return compareFunction;
364    },
365
366    /**
367     * Creates compare function for given field and direction.
368     * @param {string} field Sort field.
369     * @param {string} direction Sort direction.
370     * @param {function(*, *): number} Compare function.
371     * @private
372     */
373    sortFunction_: function(field, direction) {
374      var compareFunction = null;
375      if (field !== null)
376        compareFunction = this.createCompareFunction_(field);
377      var dirMultiplier = direction == 'desc' ? -1 : 1;
378
379      return function(index1, index2) {
380        var item1 = this.array_[index1];
381        var item2 = this.array_[index2];
382
383        var compareResult = 0;
384        if (typeof(compareFunction) === 'function')
385          compareResult = compareFunction.call(null, item1, item2);
386        if (compareResult != 0)
387          return dirMultiplier * compareResult;
388        return dirMultiplier * this.defaultValuesCompareFunction(index1,
389                                                                 index2);
390      }.bind(this);
391    },
392
393    /**
394     * Default compare function.
395     */
396    defaultValuesCompareFunction: function(a, b) {
397      // We could insert i18n comparisons here.
398      if (a < b)
399        return -1;
400      if (a > b)
401        return 1;
402      return 0;
403    }
404  };
405
406  return {
407    ArrayDataModel: ArrayDataModel
408  };
409});
410