committed by
GitHub
40 changed files with 1543 additions and 92 deletions
@ -0,0 +1,33 @@ |
|||||
|
/* |
||||
|
* Copyright © 2016-2017 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
|
||||
|
export default angular.module('thingsboard.directives.finishRender', []) |
||||
|
.directive('tbOnFinishRender', OnFinishRender) |
||||
|
.name; |
||||
|
|
||||
|
/*@ngInject*/ |
||||
|
function OnFinishRender($timeout) { |
||||
|
return { |
||||
|
restrict: 'A', |
||||
|
link: function (scope, element, attr) { |
||||
|
if (scope.$last === true) { |
||||
|
$timeout(function () { |
||||
|
scope.$emit(attr.tbOnFinishRender); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,90 @@ |
|||||
|
/* |
||||
|
* Copyright © 2016-2017 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
|
||||
|
import './material-icon-select.scss'; |
||||
|
|
||||
|
import MaterialIconsDialogController from './material-icons-dialog.controller'; |
||||
|
|
||||
|
/* eslint-disable import/no-unresolved, import/default */ |
||||
|
|
||||
|
import materialIconSelectTemplate from './material-icon-select.tpl.html'; |
||||
|
import materialIconsDialogTemplate from './material-icons-dialog.tpl.html'; |
||||
|
|
||||
|
/* eslint-enable import/no-unresolved, import/default */ |
||||
|
|
||||
|
|
||||
|
export default angular.module('thingsboard.directives.materialIconSelect', []) |
||||
|
.controller('MaterialIconsDialogController', MaterialIconsDialogController) |
||||
|
.directive('tbMaterialIconSelect', MaterialIconSelect) |
||||
|
.name; |
||||
|
|
||||
|
/*@ngInject*/ |
||||
|
function MaterialIconSelect($compile, $templateCache, $document, $mdDialog) { |
||||
|
|
||||
|
var linker = function (scope, element, attrs, ngModelCtrl) { |
||||
|
var template = $templateCache.get(materialIconSelectTemplate); |
||||
|
element.html(template); |
||||
|
|
||||
|
scope.tbRequired = angular.isDefined(scope.tbRequired) ? scope.tbRequired : false; |
||||
|
scope.icon = null; |
||||
|
|
||||
|
scope.updateView = function () { |
||||
|
ngModelCtrl.$setViewValue(scope.icon); |
||||
|
} |
||||
|
|
||||
|
ngModelCtrl.$render = function () { |
||||
|
if (ngModelCtrl.$viewValue) { |
||||
|
scope.icon = ngModelCtrl.$viewValue; |
||||
|
} |
||||
|
if (!scope.icon || !scope.icon.length) { |
||||
|
scope.icon = 'more_horiz'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
scope.$watch('icon', function () { |
||||
|
scope.updateView(); |
||||
|
}); |
||||
|
|
||||
|
scope.openIconDialog = function($event) { |
||||
|
if ($event) { |
||||
|
$event.stopPropagation(); |
||||
|
} |
||||
|
$mdDialog.show({ |
||||
|
controller: 'MaterialIconsDialogController', |
||||
|
controllerAs: 'vm', |
||||
|
templateUrl: materialIconsDialogTemplate, |
||||
|
parent: angular.element($document[0].body), |
||||
|
locals: {icon: scope.icon}, |
||||
|
skipHide: true, |
||||
|
fullscreen: true, |
||||
|
targetEvent: $event |
||||
|
}).then(function (icon) { |
||||
|
scope.icon = icon; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
$compile(element.contents())(scope); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
restrict: "E", |
||||
|
require: "^ngModel", |
||||
|
link: linker, |
||||
|
scope: { |
||||
|
tbRequired: '=?', |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2017 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
|
||||
|
.tb-material-icon-select { |
||||
|
md-icon { |
||||
|
padding: 4px; |
||||
|
margin: 8px 4px 4px; |
||||
|
cursor: pointer; |
||||
|
border: solid 1px rgba(0,0,0,0.27); |
||||
|
} |
||||
|
md-input-container { |
||||
|
margin-bottom: 0px; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2017 The Thingsboard Authors |
||||
|
|
||||
|
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
you may not use this file except in compliance with the License. |
||||
|
You may obtain a copy of the License at |
||||
|
|
||||
|
http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
|
||||
|
Unless required by applicable law or agreed to in writing, software |
||||
|
distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
See the License for the specific language governing permissions and |
||||
|
limitations under the License. |
||||
|
|
||||
|
--> |
||||
|
<div class="tb-material-icon-select" layout="row"> |
||||
|
<md-icon class="material-icons" ng-click="openIconDialog($event)">{{icon}}</md-icon> |
||||
|
<md-input-container flex> |
||||
|
<label translate>icon.icon</label> |
||||
|
<input ng-mousedown="openIconDialog($event)" ng-model="icon"> |
||||
|
</md-input-container> |
||||
|
</div> |
||||
@ -0,0 +1,59 @@ |
|||||
|
/* |
||||
|
* Copyright © 2016-2017 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
|
||||
|
import './material-icons-dialog.scss'; |
||||
|
|
||||
|
/*@ngInject*/ |
||||
|
export default function MaterialIconsDialogController($scope, $mdDialog, $timeout, utils, icon) { |
||||
|
|
||||
|
var vm = this; |
||||
|
|
||||
|
vm.selectedIcon = icon; |
||||
|
|
||||
|
vm.showAll = false; |
||||
|
vm.loadingIcons = false; |
||||
|
|
||||
|
$scope.$watch('vm.showAll', function(showAll) { |
||||
|
if (showAll) { |
||||
|
vm.loadingIcons = true; |
||||
|
$timeout(function() { |
||||
|
utils.getMaterialIcons().then( |
||||
|
function success(icons) { |
||||
|
vm.icons = icons; |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
|
} else { |
||||
|
vm.icons = utils.getCommonMaterialIcons(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
$scope.$on('iconsLoadFinished', function() { |
||||
|
vm.loadingIcons = false; |
||||
|
}); |
||||
|
|
||||
|
vm.cancel = cancel; |
||||
|
vm.selectIcon = selectIcon; |
||||
|
|
||||
|
function cancel() { |
||||
|
$mdDialog.cancel(); |
||||
|
} |
||||
|
|
||||
|
function selectIcon($event, icon) { |
||||
|
vm.selectedIcon = icon; |
||||
|
$mdDialog.hide(vm.selectedIcon); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2017 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
|
||||
|
.tb-material-icons-dialog { |
||||
|
button.md-icon-button.tb-select-icon-button { |
||||
|
border: solid 1px orange; |
||||
|
border-radius: 0%; |
||||
|
padding: 16px; |
||||
|
height: 56px; |
||||
|
width: 56px; |
||||
|
margin: 10px; |
||||
|
} |
||||
|
.tb-icons-load { |
||||
|
top: 64px; |
||||
|
background: rgba(255,255,255,0.75); |
||||
|
z-index: 3; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2017 The Thingsboard Authors |
||||
|
|
||||
|
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
you may not use this file except in compliance with the License. |
||||
|
You may obtain a copy of the License at |
||||
|
|
||||
|
http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
|
||||
|
Unless required by applicable law or agreed to in writing, software |
||||
|
distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
See the License for the specific language governing permissions and |
||||
|
limitations under the License. |
||||
|
|
||||
|
--> |
||||
|
<md-dialog class="tb-material-icons-dialog" aria-label="{{'icon.material-icons' | translate }}" style="min-width: 600px;"> |
||||
|
<form> |
||||
|
<md-toolbar> |
||||
|
<div class="md-toolbar-tools"> |
||||
|
<h2>{{ 'icon.select-icon' | translate }}</h2> |
||||
|
<span flex></span> |
||||
|
<section layout="row" layout-align="start center"> |
||||
|
<md-switch ng-model="vm.showAll" |
||||
|
aria-label="{{ 'icon.show-all' | translate }}"> |
||||
|
</md-switch> |
||||
|
<label translate>icon.show-all</label> |
||||
|
</section> |
||||
|
<md-button class="md-icon-button" ng-click="vm.cancel()"> |
||||
|
<ng-md-icon icon="close" aria-label="{{ 'action.close' | translate }}"></ng-md-icon> |
||||
|
</md-button> |
||||
|
</div> |
||||
|
</md-toolbar> |
||||
|
<md-progress-linear class="md-warn" md-mode="indeterminate" ng-disabled="!loading" ng-show="loading"></md-progress-linear> |
||||
|
<span style="min-height: 5px;" flex="" ng-show="!loading"></span> |
||||
|
<div class="tb-absolute-fill tb-icons-load" ng-show="vm.loadingIcons" layout="column" layout-align="center center"> |
||||
|
<md-progress-circular md-mode="indeterminate" ng-disabled="!vm.loadingIcons" class="md-accent" md-diameter="40"></md-progress-circular> |
||||
|
</div> |
||||
|
<md-dialog-content> |
||||
|
<div class="md-dialog-content"> |
||||
|
<md-content class="md-padding" layout="column"> |
||||
|
<fieldset ng-disabled="loading"> |
||||
|
<md-button ng-class="{'md-primary md-raised': icon == vm.selectedIcon}" class="tb-select-icon-button md-icon-button" |
||||
|
ng-repeat="icon in vm.icons" ng-click="vm.selectIcon($event, icon)" tb-on-finish-render="iconsLoadFinished"> |
||||
|
<md-icon class="material-icons">{{icon}}</md-icon> |
||||
|
<md-tooltip md-direction="bottom"> |
||||
|
{{ icon }} |
||||
|
</md-tooltip> |
||||
|
</md-button> |
||||
|
</fieldset> |
||||
|
</md-content> |
||||
|
</div> |
||||
|
</md-dialog-content> |
||||
|
<md-dialog-actions layout="row"> |
||||
|
<span flex></span> |
||||
|
<md-button ng-disabled="loading" ng-click="vm.cancel()"> |
||||
|
{{ 'action.cancel' | translate }} |
||||
|
</md-button> |
||||
|
</md-dialog-actions> |
||||
|
</form> |
||||
|
</md-dialog> |
||||
@ -0,0 +1,270 @@ |
|||||
|
/** |
||||
|
* Created by igor on 6/20/17. |
||||
|
*/ |
||||
|
/* |
||||
|
* Copyright © 2016-2017 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
|
||||
|
import './manage-widget-actions.scss'; |
||||
|
|
||||
|
import thingsboardMaterialIconSelect from '../../material-icon-select.directive'; |
||||
|
|
||||
|
import WidgetActionDialogController from './widget-action-dialog.controller'; |
||||
|
|
||||
|
/* eslint-disable import/no-unresolved, import/default */ |
||||
|
|
||||
|
import manageWidgetActionsTemplate from './manage-widget-actions.tpl.html'; |
||||
|
import widgetActionDialogTemplate from './widget-action-dialog.tpl.html'; |
||||
|
|
||||
|
/* eslint-enable import/no-unresolved, import/default */ |
||||
|
|
||||
|
export default angular.module('thingsboard.directives.widgetActions', [thingsboardMaterialIconSelect]) |
||||
|
.controller('WidgetActionDialogController', WidgetActionDialogController) |
||||
|
.directive('tbManageWidgetActions', ManageWidgetActions) |
||||
|
.name; |
||||
|
|
||||
|
/*@ngInject*/ |
||||
|
function ManageWidgetActions() { |
||||
|
return { |
||||
|
restrict: "E", |
||||
|
scope: true, |
||||
|
bindToController: { |
||||
|
actionSources: '=', |
||||
|
widgetActions: '=', |
||||
|
fetchDashboardStates: '&', |
||||
|
}, |
||||
|
controller: ManageWidgetActionsController, |
||||
|
controllerAs: 'vm', |
||||
|
templateUrl: manageWidgetActionsTemplate |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/* eslint-disable angular/angularelement */ |
||||
|
|
||||
|
|
||||
|
/*@ngInject*/ |
||||
|
function ManageWidgetActionsController($rootScope, $scope, $document, $mdDialog, $q, $filter, |
||||
|
$translate, $timeout, utils, types) { |
||||
|
|
||||
|
let vm = this; |
||||
|
|
||||
|
vm.allActions = []; |
||||
|
|
||||
|
vm.actions = []; |
||||
|
vm.actionsCount = 0; |
||||
|
|
||||
|
vm.query = { |
||||
|
order: 'actionSourceName', |
||||
|
limit: 10, |
||||
|
page: 1, |
||||
|
search: null |
||||
|
}; |
||||
|
|
||||
|
vm.enterFilterMode = enterFilterMode; |
||||
|
vm.exitFilterMode = exitFilterMode; |
||||
|
vm.onReorder = onReorder; |
||||
|
vm.onPaginate = onPaginate; |
||||
|
vm.addAction = addAction; |
||||
|
vm.editAction = editAction; |
||||
|
vm.deleteAction = deleteAction; |
||||
|
|
||||
|
$timeout(function(){ |
||||
|
$scope.manageWidgetActionsForm.querySearchInput.$pristine = false; |
||||
|
}); |
||||
|
|
||||
|
$scope.$watch('vm.widgetActions', function() { |
||||
|
if (vm.widgetActions) { |
||||
|
reloadActions(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
$scope.$watch("vm.query.search", function(newVal, prevVal) { |
||||
|
if (!angular.equals(newVal, prevVal) && vm.query.search != null) { |
||||
|
updateActions(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
function enterFilterMode () { |
||||
|
vm.query.search = ''; |
||||
|
} |
||||
|
|
||||
|
function exitFilterMode () { |
||||
|
vm.query.search = null; |
||||
|
updateActions(); |
||||
|
} |
||||
|
|
||||
|
function onReorder () { |
||||
|
updateActions(); |
||||
|
} |
||||
|
|
||||
|
function onPaginate () { |
||||
|
updateActions(); |
||||
|
} |
||||
|
|
||||
|
function addAction($event) { |
||||
|
if ($event) { |
||||
|
$event.stopPropagation(); |
||||
|
} |
||||
|
openWidgetActionDialog($event, null, true); |
||||
|
} |
||||
|
|
||||
|
function editAction ($event, action) { |
||||
|
if ($event) { |
||||
|
$event.stopPropagation(); |
||||
|
} |
||||
|
openWidgetActionDialog($event, action, false); |
||||
|
} |
||||
|
|
||||
|
function deleteAction($event, action) { |
||||
|
if ($event) { |
||||
|
$event.stopPropagation(); |
||||
|
} |
||||
|
if (action) { |
||||
|
var title = $translate.instant('widget-config.delete-action-title'); |
||||
|
var content = $translate.instant('widget-config.delete-action-text', {actionName: action.name}); |
||||
|
var confirm = $mdDialog.confirm() |
||||
|
.targetEvent($event) |
||||
|
.title(title) |
||||
|
.htmlContent(content) |
||||
|
.ariaLabel(title) |
||||
|
.cancel($translate.instant('action.no')) |
||||
|
.ok($translate.instant('action.yes')); |
||||
|
|
||||
|
confirm._options.skipHide = true; |
||||
|
confirm._options.fullscreen = true; |
||||
|
|
||||
|
$mdDialog.show(confirm).then(function () { |
||||
|
var index = getActionIndex(action.id, vm.allActions); |
||||
|
if (index > -1) { |
||||
|
vm.allActions.splice(index, 1); |
||||
|
} |
||||
|
var targetActions = vm.widgetActions[action.actionSourceId]; |
||||
|
index = getActionIndex(action.id, targetActions); |
||||
|
if (index > -1) { |
||||
|
targetActions.splice(index, 1); |
||||
|
} |
||||
|
$scope.manageWidgetActionsForm.$setDirty(); |
||||
|
updateActions(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openWidgetActionDialog($event, action, isAdd) { |
||||
|
var prevActionId = null; |
||||
|
if (!isAdd) { |
||||
|
prevActionId = action.id; |
||||
|
} |
||||
|
var availableActionSources = {}; |
||||
|
for (var id in vm.actionSources) { |
||||
|
var actionSource = vm.actionSources[id]; |
||||
|
if (actionSource.multiple) { |
||||
|
availableActionSources[id] = actionSource; |
||||
|
} else { |
||||
|
if (!isAdd && action.actionSourceId == id) { |
||||
|
availableActionSources[id] = actionSource; |
||||
|
} else { |
||||
|
var result = $filter('filter')(vm.allActions, {actionSourceId: id}); |
||||
|
if (!result || !result.length) { |
||||
|
availableActionSources[id] = actionSource; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
$mdDialog.show({ |
||||
|
controller: 'WidgetActionDialogController', |
||||
|
controllerAs: 'vm', |
||||
|
templateUrl: widgetActionDialogTemplate, |
||||
|
parent: angular.element($document[0].body), |
||||
|
locals: {isAdd: isAdd, fetchDashboardStates: vm.fetchDashboardStates, |
||||
|
actionSources: availableActionSources, widgetActions: vm.widgetActions, |
||||
|
action: angular.copy(action)}, |
||||
|
skipHide: true, |
||||
|
fullscreen: true, |
||||
|
targetEvent: $event |
||||
|
}).then(function (action) { |
||||
|
saveAction(action, prevActionId); |
||||
|
updateActions(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function getActionIndex(id, actions) { |
||||
|
var result = $filter('filter')(actions, {id: id}, true); |
||||
|
if (result && result.length) { |
||||
|
return actions.indexOf(result[0]); |
||||
|
} |
||||
|
return -1; |
||||
|
} |
||||
|
|
||||
|
function saveAction(action, prevActionId) { |
||||
|
var actionSourceName = vm.actionSources[action.actionSourceId].name; |
||||
|
action.actionSourceName = utils.customTranslation(actionSourceName, actionSourceName); |
||||
|
action.typeName = $translate.instant(types.widgetActionTypes[action.type].name); |
||||
|
var actionSourceId = action.actionSourceId; |
||||
|
var widgetAction = angular.copy(action); |
||||
|
delete widgetAction.actionSourceId; |
||||
|
delete widgetAction.actionSourceName; |
||||
|
delete widgetAction.typeName; |
||||
|
var targetActions = vm.widgetActions[actionSourceId]; |
||||
|
if (!targetActions) { |
||||
|
targetActions = []; |
||||
|
vm.widgetActions[actionSourceId] = targetActions; |
||||
|
} |
||||
|
if (prevActionId) { |
||||
|
var index = getActionIndex(prevActionId, vm.allActions); |
||||
|
if (index > -1) { |
||||
|
vm.allActions[index] = action; |
||||
|
} |
||||
|
index = getActionIndex(prevActionId, targetActions); |
||||
|
if (index > -1) { |
||||
|
targetActions[index] = widgetAction; |
||||
|
} |
||||
|
} else { |
||||
|
vm.allActions.push(action); |
||||
|
targetActions.push(widgetAction); |
||||
|
} |
||||
|
$scope.manageWidgetActionsForm.$setDirty(); |
||||
|
} |
||||
|
|
||||
|
function reloadActions() { |
||||
|
vm.allActions = []; |
||||
|
vm.actions = []; |
||||
|
vm.actionsCount = 0; |
||||
|
|
||||
|
for (var actionSourceId in vm.widgetActions) { |
||||
|
var actionSource = vm.actionSources[actionSourceId]; |
||||
|
var actionSourceActions = vm.widgetActions[actionSourceId]; |
||||
|
for (var i=0;i<actionSourceActions.length;i++) { |
||||
|
var actionSourceAction = actionSourceActions[i]; |
||||
|
var action = angular.copy(actionSourceAction); |
||||
|
action.actionSourceId = actionSourceId; |
||||
|
action.actionSourceName = utils.customTranslation(actionSource.name, actionSource.name); |
||||
|
action.typeName = $translate.instant(types.widgetActionTypes[actionSourceAction.type].name); |
||||
|
vm.allActions.push(action); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
updateActions (); |
||||
|
} |
||||
|
|
||||
|
function updateActions () { |
||||
|
var result = $filter('orderBy')(vm.allActions, vm.query.order); |
||||
|
if (vm.query.search != null) { |
||||
|
result = $filter('filter')(result, {$: vm.query.search}); |
||||
|
} |
||||
|
vm.actionsCount = result.length; |
||||
|
var startIndex = vm.query.limit * (vm.query.page - 1); |
||||
|
vm.actions = result.slice(startIndex, startIndex + vm.query.limit); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2017 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
.tb-manage-widget-actions { |
||||
|
table.md-table { |
||||
|
tbody { |
||||
|
tr { |
||||
|
td { |
||||
|
&.tb-action-cell { |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
min-width: 100px; |
||||
|
max-width: 100px; |
||||
|
width: 100px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,99 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2017 The Thingsboard Authors |
||||
|
|
||||
|
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
you may not use this file except in compliance with the License. |
||||
|
You may obtain a copy of the License at |
||||
|
|
||||
|
http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
|
||||
|
Unless required by applicable law or agreed to in writing, software |
||||
|
distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
See the License for the specific language governing permissions and |
||||
|
limitations under the License. |
||||
|
|
||||
|
--> |
||||
|
<div ng-form="manageWidgetActionsForm" class="tb-manage-widget-actions md-whiteframe-z1" layout="column"> |
||||
|
<md-toolbar class="md-table-toolbar md-default" ng-show="vm.query.search === null"> |
||||
|
<div class="md-toolbar-tools"> |
||||
|
<span translate>widget-config.actions</span> |
||||
|
<span flex></span> |
||||
|
<md-button class="md-icon-button" ng-click="vm.addAction($event)"> |
||||
|
<md-icon>add</md-icon> |
||||
|
<md-tooltip md-direction="top"> |
||||
|
{{ 'widget-config.add-action' | translate }} |
||||
|
</md-tooltip> |
||||
|
</md-button> |
||||
|
<md-button class="md-icon-button" ng-click="vm.enterFilterMode()"> |
||||
|
<md-icon>search</md-icon> |
||||
|
<md-tooltip md-direction="top"> |
||||
|
{{ 'action.search' | translate }} |
||||
|
</md-tooltip> |
||||
|
</md-button> |
||||
|
</div> |
||||
|
</md-toolbar> |
||||
|
<md-toolbar class="md-table-toolbar md-default" ng-show="vm.query.search != null"> |
||||
|
<div class="md-toolbar-tools"> |
||||
|
<md-button class="md-icon-button" aria-label="{{ 'action.search' | translate }}"> |
||||
|
<md-icon aria-label="{{ 'action.search' | translate }}" class="material-icons">search</md-icon> |
||||
|
<md-tooltip md-direction="top"> |
||||
|
{{ 'widget-config.search-actions' | translate }} |
||||
|
</md-tooltip> |
||||
|
</md-button> |
||||
|
<md-input-container flex> |
||||
|
<label> </label> |
||||
|
<input ng-model="vm.query.search" name="querySearchInput" placeholder="{{ 'widget-config.search-actions' | translate }}"/> |
||||
|
</md-input-container> |
||||
|
<md-button class="md-icon-button" aria-label="Close" ng-click="vm.exitFilterMode()"> |
||||
|
<md-icon aria-label="Close" class="material-icons">close</md-icon> |
||||
|
<md-tooltip md-direction="top"> |
||||
|
{{ 'action.close' | translate }} |
||||
|
</md-tooltip> |
||||
|
</md-button> |
||||
|
</div> |
||||
|
</md-toolbar> |
||||
|
<md-table-container> |
||||
|
<table md-table> |
||||
|
<thead md-head md-order="vm.query.order" md-on-reorder="vm.onReorder"> |
||||
|
<tr md-row> |
||||
|
<th md-column md-order-by="actionSourceName"><span translate>widget-config.action-source</span></th> |
||||
|
<th md-column md-order-by="name"><span translate>widget-config.action-name</span></th> |
||||
|
<th md-column md-order-by="icon"><span translate>widget-config.action-icon</span></th> |
||||
|
<th md-column md-order-by="typeName"><span translate>widget-config.action-type</span></th> |
||||
|
<th md-column><span> </span></th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody md-body> |
||||
|
<tr md-row ng-repeat="action in vm.actions"> |
||||
|
<td md-cell>{{action.actionSourceName}}</td> |
||||
|
<td md-cell>{{action.name}}</td> |
||||
|
<td md-cell> |
||||
|
<md-icon aria-label="{{ 'widget-config.action-icon' | translate }}" class="material-icons">{{action.icon}}</md-icon> |
||||
|
</td> |
||||
|
<td md-cell>{{action.typeName}}</td> |
||||
|
<td md-cell class="tb-action-cell"> |
||||
|
<md-button class="md-icon-button" aria-label="{{ 'action.edit' | translate }}" |
||||
|
ng-click="vm.editAction($event, action)"> |
||||
|
<md-icon aria-label="{{ 'action.edit' | translate }}" class="material-icons">edit</md-icon> |
||||
|
<md-tooltip md-direction="top"> |
||||
|
{{ 'widget-config.edit-action' | translate }} |
||||
|
</md-tooltip> |
||||
|
</md-button> |
||||
|
<md-button class="md-icon-button" aria-label="{{'action.delete' | translate}}" ng-click="vm.deleteAction($event, action)"> |
||||
|
<md-icon aria-label="Delete" class="material-icons">delete</md-icon> |
||||
|
<md-tooltip md-direction="top"> |
||||
|
{{ 'widget-config.delete-action' | translate }} |
||||
|
</md-tooltip> |
||||
|
</md-button> |
||||
|
</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</md-table-container> |
||||
|
<md-table-pagination md-limit="vm.query.limit" md-limit-options="[10, 15, 20]" |
||||
|
md-page="vm.query.page" md-total="{{vm.actionsCount}}" |
||||
|
md-on-paginate="vm.onPaginate" md-page-select> |
||||
|
</md-table-pagination> |
||||
|
</div> |
||||
@ -0,0 +1,169 @@ |
|||||
|
/* |
||||
|
* Copyright © 2016-2017 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
|
||||
|
/*@ngInject*/ |
||||
|
export default function WidgetActionDialogController($scope, $mdDialog, $filter, $q, dashboardService, dashboardUtils, types, utils, |
||||
|
isAdd, fetchDashboardStates, actionSources, widgetActions, action) { |
||||
|
|
||||
|
var vm = this; |
||||
|
|
||||
|
vm.types = types; |
||||
|
|
||||
|
vm.isAdd = isAdd; |
||||
|
vm.fetchDashboardStates = fetchDashboardStates; |
||||
|
vm.actionSources = actionSources; |
||||
|
vm.widgetActions = widgetActions; |
||||
|
|
||||
|
vm.targetDashboardStateSearchText = ''; |
||||
|
|
||||
|
vm.selectedDashboardStateIds = []; |
||||
|
|
||||
|
if (vm.isAdd) { |
||||
|
vm.action = { |
||||
|
id: utils.guid() |
||||
|
}; |
||||
|
} else { |
||||
|
vm.action = action; |
||||
|
} |
||||
|
|
||||
|
vm.actionSourceName = actionSourceName; |
||||
|
|
||||
|
vm.targetDashboardStateSearchTextChanged = function() { |
||||
|
} |
||||
|
|
||||
|
vm.dashboardStateSearch = dashboardStateSearch; |
||||
|
vm.cancel = cancel; |
||||
|
vm.save = save; |
||||
|
|
||||
|
$scope.$watch("vm.action.name", function(newVal, prevVal) { |
||||
|
if (!angular.equals(newVal, prevVal) && vm.action.name != null) { |
||||
|
checkActionName(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
$scope.$watch("vm.action.actionSourceId", function(newVal, prevVal) { |
||||
|
if (!angular.equals(newVal, prevVal) && vm.action.actionSourceId != null) { |
||||
|
checkActionName(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
$scope.$watch("vm.action.targetDashboardId", function() { |
||||
|
vm.selectedDashboardStateIds = []; |
||||
|
if (vm.action.targetDashboardId) { |
||||
|
dashboardService.getDashboard(vm.action.targetDashboardId).then( |
||||
|
function success(dashboard) { |
||||
|
dashboard = dashboardUtils.validateAndUpdateDashboard(dashboard); |
||||
|
var states = dashboard.configuration.states; |
||||
|
vm.selectedDashboardStateIds = Object.keys(states); |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
$scope.$watch('vm.action.type', function(newType) { |
||||
|
if (newType) { |
||||
|
switch (newType) { |
||||
|
case vm.types.widgetActionTypes.openDashboardState.value: |
||||
|
case vm.types.widgetActionTypes.updateDashboardState.value: |
||||
|
case vm.types.widgetActionTypes.openDashboard.value: |
||||
|
if (angular.isUndefined(vm.action.setEntityId)) { |
||||
|
vm.action.setEntityId = true; |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
function checkActionName() { |
||||
|
var actionNameIsUnique = true; |
||||
|
if (vm.action.actionSourceId && vm.action.name) { |
||||
|
var sourceActions = vm.widgetActions[vm.action.actionSourceId]; |
||||
|
if (sourceActions) { |
||||
|
var result = $filter('filter')(sourceActions, {name: vm.action.name}, true); |
||||
|
if (result && result.length && result[0].id !== vm.action.id) { |
||||
|
actionNameIsUnique = false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
$scope.theForm.name.$setValidity('actionNameNotUnique', actionNameIsUnique); |
||||
|
} |
||||
|
|
||||
|
function actionSourceName (actionSource) { |
||||
|
if (actionSource) { |
||||
|
return utils.customTranslation(actionSource.name, actionSource.name); |
||||
|
} else { |
||||
|
return ''; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function dashboardStateSearch (query) { |
||||
|
if (vm.action.type == vm.types.widgetActionTypes.openDashboard.value) { |
||||
|
var deferred = $q.defer(); |
||||
|
var result = query ? vm.selectedDashboardStateIds.filter( |
||||
|
createFilterForDashboardState(query)) : vm.selectedDashboardStateIds; |
||||
|
if (result && result.length) { |
||||
|
deferred.resolve(result); |
||||
|
} else { |
||||
|
deferred.resolve([query]); |
||||
|
} |
||||
|
return deferred.promise; |
||||
|
} else { |
||||
|
return vm.fetchDashboardStates({query: query}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function createFilterForDashboardState (query) { |
||||
|
var lowercaseQuery = angular.lowercase(query); |
||||
|
return function filterFn(stateId) { |
||||
|
return (angular.lowercase(stateId).indexOf(lowercaseQuery) === 0); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function cleanupAction(action) { |
||||
|
var result = {}; |
||||
|
result.id = action.id; |
||||
|
result.actionSourceId = action.actionSourceId; |
||||
|
result.name = action.name; |
||||
|
result.icon = action.icon; |
||||
|
result.type = action.type; |
||||
|
switch (action.type) { |
||||
|
case vm.types.widgetActionTypes.openDashboardState.value: |
||||
|
case vm.types.widgetActionTypes.updateDashboardState.value: |
||||
|
result.targetDashboardStateId = action.targetDashboardStateId; |
||||
|
result.openRightLayout = action.openRightLayout; |
||||
|
result.setEntityId = action.setEntityId; |
||||
|
break; |
||||
|
case vm.types.widgetActionTypes.openDashboard.value: |
||||
|
result.targetDashboardId = action.targetDashboardId; |
||||
|
result.targetDashboardStateId = action.targetDashboardStateId; |
||||
|
result.setEntityId = action.setEntityId; |
||||
|
break; |
||||
|
case vm.types.widgetActionTypes.custom.value: |
||||
|
result.customFunction = action.customFunction; |
||||
|
break; |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
function cancel() { |
||||
|
$mdDialog.cancel(); |
||||
|
} |
||||
|
|
||||
|
function save() { |
||||
|
$scope.theForm.$setPristine(); |
||||
|
$mdDialog.hide(cleanupAction(vm.action)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,133 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2017 The Thingsboard Authors |
||||
|
|
||||
|
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
you may not use this file except in compliance with the License. |
||||
|
You may obtain a copy of the License at |
||||
|
|
||||
|
http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
|
||||
|
Unless required by applicable law or agreed to in writing, software |
||||
|
distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
See the License for the specific language governing permissions and |
||||
|
limitations under the License. |
||||
|
|
||||
|
--> |
||||
|
<md-dialog class="tb-widget-action-dialog" aria-label="{{'widget-config.action' | translate }}" style="min-width: 600px;"> |
||||
|
<form name="theForm" ng-submit="vm.save()"> |
||||
|
<md-toolbar> |
||||
|
<div class="md-toolbar-tools"> |
||||
|
<h2>{{ (vm.isAdd ? 'widget-config.add-action' : 'widget-config.edit-action') | translate }}</h2> |
||||
|
<span flex></span> |
||||
|
<md-button class="md-icon-button" ng-click="vm.cancel()"> |
||||
|
<ng-md-icon icon="close" aria-label="{{ 'action.close' | translate }}"></ng-md-icon> |
||||
|
</md-button> |
||||
|
</div> |
||||
|
</md-toolbar> |
||||
|
<md-progress-linear class="md-warn" md-mode="indeterminate" ng-disabled="!loading" ng-show="loading"></md-progress-linear> |
||||
|
<span style="min-height: 5px;" flex="" ng-show="!loading"></span> |
||||
|
<md-dialog-content> |
||||
|
<div class="md-dialog-content"> |
||||
|
<md-content class="md-padding" layout="column"> |
||||
|
<fieldset ng-disabled="loading" layout="column"> |
||||
|
<md-input-container class="md-block"> |
||||
|
<label translate>widget-config.action-source</label> |
||||
|
<md-select name="actionSource" required aria-label="{{ 'widget-config.action-source' | translate }}" ng-model="vm.action.actionSourceId"> |
||||
|
<md-option ng-repeat="(actionSourceId, actionSource) in vm.actionSources" ng-value="actionSourceId"> |
||||
|
{{vm.actionSourceName(actionSource)}} |
||||
|
</md-option> |
||||
|
</md-select> |
||||
|
<div ng-messages="theForm.actionSource.$error"> |
||||
|
<div ng-message="required" translate>widget-config.action-source-required</div> |
||||
|
</div> |
||||
|
</md-input-container> |
||||
|
<md-input-container class="md-block"> |
||||
|
<label translate>widget-config.action-name</label> |
||||
|
<input name="name" required ng-model="vm.action.name"> |
||||
|
<div ng-messages="theForm.name.$error"> |
||||
|
<div ng-message="required" translate>widget-config.action-name-required</div> |
||||
|
<div ng-message="actionNameNotUnique" translate>widget-config.action-name-not-unique</div> |
||||
|
</div> |
||||
|
</md-input-container> |
||||
|
<tb-material-icon-select ng-model="vm.action.icon"> |
||||
|
</tb-material-icon-select> |
||||
|
<md-input-container class="md-block"> |
||||
|
<label translate>widget-config.action-type</label> |
||||
|
<md-select name="actionType" required aria-label="{{ 'widget-config.action-type' | translate }}" ng-model="vm.action.type"> |
||||
|
<md-option ng-repeat="actionType in vm.types.widgetActionTypes" ng-value="actionType.value"> |
||||
|
{{ actionType.name | translate }} |
||||
|
</md-option> |
||||
|
</md-select> |
||||
|
<div ng-messages="theForm.actionType.$error"> |
||||
|
<div ng-message="required" translate>widget-config.action-type-required</div> |
||||
|
</div> |
||||
|
</md-input-container> |
||||
|
<div layout="column" |
||||
|
style="padding-bottom: 20px;" |
||||
|
ng-if="vm.action.type == vm.types.widgetActionTypes.openDashboard.value"> |
||||
|
<div class="md-caption tb-required" |
||||
|
style="padding-left: 3px; padding-bottom: 10px; color: rgba(0,0,0,0.57);" translate>widget-action.target-dashboard</div> |
||||
|
<tb-dashboard-autocomplete the-form="theForm" |
||||
|
tb-required="true" |
||||
|
ng-model="vm.action.targetDashboardId" |
||||
|
select-first-dashboard="false"> |
||||
|
</tb-dashboard-autocomplete> |
||||
|
</div> |
||||
|
<md-autocomplete ng-if="vm.action.type == vm.types.widgetActionTypes.openDashboardState.value || |
||||
|
vm.action.type == vm.types.widgetActionTypes.updateDashboardState.value || |
||||
|
vm.action.type == vm.types.widgetActionTypes.openDashboard.value" |
||||
|
ng-required="vm.action.type == vm.types.widgetActionTypes.openDashboardState.value" |
||||
|
md-no-cache="true" |
||||
|
md-input-name="targetDashboardState" |
||||
|
ng-model="vm.action.targetDashboardStateId" |
||||
|
md-selected-item="vm.action.targetDashboardStateId" |
||||
|
md-search-text="vm.targetDashboardStateSearchText" |
||||
|
md-search-text-change="vm.targetDashboardStateSearchTextChanged()" |
||||
|
md-items="item in vm.dashboardStateSearch(vm.targetDashboardStateSearchText)" |
||||
|
md-item-text="item" |
||||
|
md-min-length="0" |
||||
|
md-floating-label="{{ 'widget-action.target-dashboard-state' | translate }}" |
||||
|
md-select-on-match="true"> |
||||
|
<md-item-template> |
||||
|
<div> |
||||
|
<span md-highlight-text="vm.targetDashboardStateSearchText" md-highlight-flags="^i">{{item}}</span> |
||||
|
</div> |
||||
|
</md-item-template> |
||||
|
<div ng-messages="theForm.targetDashboardState.$error"> |
||||
|
<div translate ng-message="required">widget-action.target-dashboard-state-required</div> |
||||
|
</div> |
||||
|
</md-autocomplete> |
||||
|
<md-checkbox ng-if="vm.action.type == vm.types.widgetActionTypes.openDashboardState.value || |
||||
|
vm.action.type == vm.types.widgetActionTypes.updateDashboardState.value" |
||||
|
flex aria-label="{{ 'widget-action.open-right-layout' | translate }}" |
||||
|
ng-model="vm.action.openRightLayout">{{ 'widget-action.open-right-layout' | translate }} |
||||
|
</md-checkbox> |
||||
|
<md-checkbox ng-if="vm.action.type == vm.types.widgetActionTypes.openDashboardState.value || |
||||
|
vm.action.type == vm.types.widgetActionTypes.updateDashboardState.value || |
||||
|
vm.action.type == vm.types.widgetActionTypes.openDashboard.value" |
||||
|
flex aria-label="{{ 'widget-action.set-entity-from-widget' | translate }}" |
||||
|
ng-model="vm.action.setEntityId">{{ 'widget-action.set-entity-from-widget' | translate }} |
||||
|
</md-checkbox> |
||||
|
<tb-js-func ng-if="vm.action.type == vm.types.widgetActionTypes.custom.value" |
||||
|
ng-model="vm.action.customFunction" |
||||
|
function-args="{{ ['$event', 'widgetContext', 'entityId'] }}" |
||||
|
validation-args="{{ [] }}"> |
||||
|
</tb-js-func> |
||||
|
</fieldset> |
||||
|
</md-content> |
||||
|
</div> |
||||
|
</md-dialog-content> |
||||
|
<md-dialog-actions layout="row"> |
||||
|
<span flex></span> |
||||
|
<md-button ng-disabled="loading || theForm.$invalid || !theForm.$dirty" type="submit" |
||||
|
class="md-raised md-primary"> |
||||
|
{{ (vm.isAdd ? 'action.add' : 'action.save') | translate }} |
||||
|
</md-button> |
||||
|
<md-button ng-disabled="loading" ng-click="vm.cancel()" style="margin-right:20px;"> |
||||
|
{{ 'action.cancel' | translate }} |
||||
|
</md-button> |
||||
|
</md-dialog-actions> |
||||
|
</form> |
||||
|
</md-dialog> |
||||
Loading…
Reference in new issue