blob: 1e3d50ebeb9649053c8081f6dbe2ecb3ac3f7060 [file] [log] [blame]
var patchArray = require('./../util/patch-array')
module.exports = function DeviceListIconsDirective(
$filter
, gettext
, DeviceColumnService
, GroupService
, StandaloneService
) {
function DeviceItem() {
return {
build: function() {
var li = document.createElement('li')
li.className = 'cursor-select pointer thumbnail'
// the whole li is a link
var a = document.createElement('a')
li.appendChild(a)
// .device-photo-small
var photo = document.createElement('div')
photo.className = 'device-photo-small'
var img = document.createElement('img')
photo.appendChild(img)
a.appendChild(photo)
// .device-name
var name = document.createElement('div')
name.className = 'device-name'
name.appendChild(document.createTextNode(''))
a.appendChild(name)
// button
var button = document.createElement('button')
button.appendChild(document.createTextNode(''))
a.appendChild(button)
return li
}
, update: function(li, device) {
var a = li.firstChild
var img = a.firstChild.firstChild
var name = a.firstChild.nextSibling
var nt = name.firstChild
var button = name.nextSibling
var at = button.firstChild
var classes = 'btn btn-xs device-status '
// .device-photo-small
if (img.getAttribute('src') !== device.enhancedImage120) {
img.setAttribute('src', device.enhancedImage120)
}
// .device-name
nt.nodeValue = device.enhancedName
// button
at.nodeValue = $filter('translate')(device.enhancedStateAction)
function getStateClasses(state) {
var stateClasses = {
using: 'state-using btn-primary',
busy: 'state-busy btn-warning',
available: 'state-available btn-primary-outline',
ready: 'state-ready btn-primary-outline',
present: 'state-present btn-primary-outline',
preparing: 'state-preparing btn-primary-outline btn-success-outline',
unauthorized: 'state-unauthorized btn-danger-outline',
offline: 'state-offline btn-warning-outline'
}[state]
if (typeof stateClasses === 'undefined') {
stateClasses = 'btn-default-outline'
}
return stateClasses
}
button.className = classes + getStateClasses(device.state)
if (device.state === 'available') {
name.classList.add('state-available')
} else {
name.classList.remove('state-available')
}
if (device.usable) {
a.href = '#!/control/' + device.serial
li.classList.remove('device-is-busy')
}
else {
a.removeAttribute('href')
li.classList.add('device-is-busy')
}
return li
}
}
}
return {
restrict: 'E'
, template: require('./device-list-icons.jade')
, scope: {
tracker: '&tracker'
, columns: '&columns'
, sort: '=sort'
, filter: '&filter'
}
, link: function(scope, element) {
var tracker = scope.tracker()
var activeColumns = []
var activeSorting = []
var activeFilters = []
var list = element.find('ul')[0]
var items = list.childNodes
var prefix = 'd' + Math.floor(Math.random() * 1000000) + '-'
var mapping = Object.create(null)
var builder = DeviceItem()
function kickDevice(device, force) {
return GroupService.kick(device, force).catch(function(e) {
alert($filter('translate')(gettext('Device cannot get kicked from the group')))
throw new Error(e)
})
}
function inviteDevice(device) {
return GroupService.invite(device).then(function() {
scope.$digest()
})
}
element.on('click', function(e) {
var id
if (e.target.classList.contains('thumbnail')) {
id = e.target.id
} else if (e.target.classList.contains('device-status') ||
e.target.classList.contains('device-photo-small') ||
e.target.classList.contains('device-name')) {
id = e.target.parentNode.parentNode.id
} else if (e.target.parentNode.classList.contains('device-photo-small')) {
id = e.target.parentNode.parentNode.parentNode.id
}
if (id) {
var device = mapping[id]
if (e.altKey && device.state === 'available') {
inviteDevice(device)
e.preventDefault()
}
if (e.shiftKey && device.state === 'available') {
StandaloneService.open(device)
e.preventDefault()
}
if (device.using) {
kickDevice(device)
e.preventDefault()
}
}
})
// Import column definitions
scope.columnDefinitions = DeviceColumnService
// Sorting
scope.sortBy = function(column, multiple) {
function findInSorting(sorting) {
for (var i = 0, l = sorting.length; i < l; ++i) {
if (sorting[i].name === column.name) {
return sorting[i]
}
}
return null
}
var swap = {
asc: 'desc'
, desc: 'asc'
}
var fixedMatch = findInSorting(scope.sort.fixed)
if (fixedMatch) {
fixedMatch.order = swap[fixedMatch.order]
return
}
var userMatch = findInSorting(scope.sort.user)
if (userMatch) {
userMatch.order = swap[userMatch.order]
if (!multiple) {
scope.sort.user = [userMatch]
}
}
else {
if (!multiple) {
scope.sort.user = []
}
scope.sort.user.push({
name: column.name
, order: scope.columnDefinitions[column.name].defaultOrder || 'asc'
})
}
}
// Watch for sorting changes
scope.$watch(
function() {
return scope.sort
}
, function(newValue) {
activeSorting = newValue.fixed.concat(newValue.user)
scope.sortedColumns = Object.create(null)
activeSorting.forEach(function(sort) {
scope.sortedColumns[sort.name] = sort
})
sortAll()
}
, true
)
// Watch for column updates
scope.$watch(
function() {
return scope.columns()
}
, function(newValue) {
updateColumns(newValue)
}
, true
)
// Update now so that we don't have to wait for the scope watcher to
// trigger.
updateColumns(scope.columns())
// Updates visible columns. This method doesn't necessarily have to be
// the fastest because it shouldn't get called all the time.
function updateColumns(columnSettings) {
var newActiveColumns = []
// Check what we're supposed to show now
columnSettings.forEach(function(column) {
if (column.selected) {
newActiveColumns.push(column.name)
}
})
// Figure out the patch
var patch = patchArray(activeColumns, newActiveColumns)
// Set up new active columns
activeColumns = newActiveColumns
return patchAll(patch)
}
// Updates filters on visible items.
function updateFilters(filters) {
activeFilters = filters
return filterAll()
}
// Applies filteItem() to all items.
function filterAll() {
for (var i = 0, l = items.length; i < l; ++i) {
filterItem(items[i], mapping[items[i].id])
}
}
// Filters an item, perhaps removing it from view.
function filterItem(item, device) {
if (match(device)) {
item.classList.remove('filter-out')
}
else {
item.classList.add('filter-out')
}
}
// Checks whether the device matches the currently active filters.
function match(device) {
for (var i = 0, l = activeFilters.length; i < l; ++i) {
var filter = activeFilters[i]
var column
if (filter.field) {
column = scope.columnDefinitions[filter.field]
if (column && !column.filter(device, filter)) {
return false
}
}
else {
var found = false
for (var j = 0, k = activeColumns.length; j < k; ++j) {
column = scope.columnDefinitions[activeColumns[j]]
if (column && column.filter(device, filter)) {
found = true
break
}
}
if (!found) {
return false
}
}
}
return true
}
// Update now so we're up to date.
updateFilters(scope.filter())
// Watch for filter updates.
scope.$watch(
function() {
return scope.filter()
}
, function(newValue) {
updateFilters(newValue)
}
, true
)
// Calculates a DOM ID for the device. Should be consistent for the
// same device within the same table, but unique among other tables.
function calculateId(device) {
return prefix + device.serial
}
// Compares two devices using the currently active sorting. Returns <0
// if deviceA is smaller, >0 if deviceA is bigger, or 0 if equal.
var compare = (function() {
var mapping = {
asc: 1
, desc: -1
}
return function(deviceA, deviceB) {
var diff
// Find the first difference
for (var i = 0, l = activeSorting.length; i < l; ++i) {
var sort = activeSorting[i]
diff = scope.columnDefinitions[sort.name].compare(deviceA, deviceB)
if (diff !== 0) {
diff *= mapping[sort.order]
break
}
}
return diff
}
})()
// Creates a completely new item for the device. Means that this is
// the first time we see the device.
function createItem(device) {
var id = calculateId(device)
var item = builder.build()
item.id = id
builder.update(item, device)
mapping[id] = device
return item
}
// Patches all items.
function patchAll(patch) {
for (var i = 0, l = items.length; i < l; ++i) {
patchItem(items[i], mapping[items[i].id], patch)
}
}
// Patches the given item by running the given patch operations in
// order. The operations must take into account index changes caused
// by previous operations.
function patchItem(/*item, device, patch*/) {
// Currently no-op
}
// Updates all the columns in the item. Note that the item must be in
// the right format already (built with createItem() and patched with
// patchItem() if necessary).
function updateItem(item, device) {
var id = calculateId(device)
item.id = id
builder.update(item, device)
return item
}
// Inserts an item into the table into its correct position according to
// current sorting.
function insertItem(item, deviceA) {
return insertItemToSegment(item, deviceA, 0, items.length - 1)
}
// Inserts an item into a segment of the table into its correct position
// according to current sorting. The value of `hi` is the index
// of the last item in the segment, or -1 if none. The value of `lo`
// is the index of the first item in the segment, or 0 if none.
function insertItemToSegment(item, deviceA, low, high) {
var total = items.length
var lo = low
var hi = high
if (lo > hi) {
// This means that `lo` refers to the first item of the next
// segment (which may or may not exist), and we should put the
// row before it.
list.insertBefore(item, lo < total ? items[lo] : null)
}
else {
var after = true
var pivot = 0
var deviceB
while (lo <= hi) {
pivot = ~~((lo + hi) / 2)
deviceB = mapping[items[pivot].id]
var diff = compare(deviceA, deviceB)
if (diff === 0) {
after = true
break
}
if (diff < 0) {
hi = pivot - 1
after = false
}
else {
lo = pivot + 1
after = true
}
}
if (after) {
list.insertBefore(item, items[pivot].nextSibling)
}
else {
list.insertBefore(item, items[pivot])
}
}
}
// Compares an item to its siblings to see if it's still in the correct
// position. Returns <0 if the device should actually go somewhere
// before the previous item, >0 if it should go somewhere after the next
// item, or 0 if the position is already correct.
function compareItem(item, device) {
var prev = item.previousSibling
var next = item.nextSibling
var diff
if (prev) {
diff = compare(device, mapping[prev.id])
if (diff < 0) {
return diff
}
}
if (next) {
diff = compare(device, mapping[next.id])
if (diff > 0) {
return diff
}
}
return 0
}
// Sort all items.
function sortAll() {
// This could be improved by getting rid of the array copying. The
// copy is made because items can't be sorted directly.
var sorted = [].slice.call(items).sort(function(itemA, itemB) {
return compare(mapping[itemA.id], mapping[itemB.id])
})
// Now, if we just append all the elements, they will be in the
// correct order in the table.
for (var i = 0, l = sorted.length; i < l; ++i) {
list.appendChild(sorted[i])
}
}
// Triggers when the tracker sees a device for the first time.
function addListener(device) {
var item = createItem(device)
filterItem(item, device)
insertItem(item, device)
}
// Triggers when the tracker notices that a device changed.
function changeListener(device) {
var id = calculateId(device)
var item = list.children[id]
if (item) {
// First, update columns
updateItem(item, device)
// Maybe the item is not sorted correctly anymore?
var diff = compareItem(item, device)
if (diff !== 0) {
// Because the item is no longer sorted correctly, we must
// remove it so that it doesn't confuse the binary search.
// Then we will simply add it back.
list.removeChild(item)
insertItem(item, device)
}
}
}
// Triggers when a device is removed entirely from the tracker.
function removeListener(device) {
var id = calculateId(device)
var item = list.children[id]
if (item) {
list.removeChild(item)
}
delete mapping[id]
}
tracker.on('add', addListener)
tracker.on('change', changeListener)
tracker.on('remove', removeListener)
// Maybe we're already late
tracker.devices.forEach(addListener)
scope.$on('$destroy', function() {
tracker.removeListener('add', addListener)
tracker.removeListener('change', changeListener)
tracker.removeListener('remove', removeListener)
})
}
}
}