Refactor the entity-search directive/control to make its interface much cleaner and to add support for ng-model validity checking
This commit is contained in:
parent
df7b8d651c
commit
3865e3b1b7
8 changed files with 234 additions and 155 deletions
|
@ -219,11 +219,12 @@ nav.navbar-default .navbar-nav>li>a {
|
|||
|
||||
.entity-search-element {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.entity-search-element .entity-reference {
|
||||
position: absolute !important;
|
||||
top: 0px;
|
||||
top: 7px;
|
||||
left: 8px;
|
||||
right: 36px;
|
||||
z-index: 0;
|
||||
|
@ -244,6 +245,7 @@ nav.navbar-default .navbar-nav>li>a {
|
|||
|
||||
.entity-search-element input {
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entity-search-element.persistent input {
|
||||
|
@ -253,12 +255,15 @@ nav.navbar-default .navbar-nav>li>a {
|
|||
|
||||
.entity-search-element .twitter-typeahead {
|
||||
vertical-align: middle;
|
||||
display: block !important;
|
||||
margin-right: 36px;
|
||||
}
|
||||
|
||||
.entity-search-element .dropdown {
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin-top: 0px;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.dropdown-menu i.fa {
|
||||
|
@ -2508,10 +2513,6 @@ p.editable:hover i {
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
.repo-admin .entity-search input {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.repo-admin .panel {
|
||||
display: inline-block;
|
||||
width: 720px;
|
||||
|
@ -3023,7 +3024,6 @@ p.editable:hover i {
|
|||
|
||||
.team-view .entity-search {
|
||||
margin-top: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.team-view .delete-ui {
|
||||
|
|
|
@ -62,6 +62,12 @@
|
|||
<input type="email" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="email" required>
|
||||
<input type="url" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="url" required>
|
||||
<input type="text" class="form-control" ng-model="currentConfig[field.name]" ng-switch-when="string" required>
|
||||
<div class="entity-search" namespace="repository.namespace"
|
||||
placeholder="''"
|
||||
current-entity="currentConfig[field.name]"
|
||||
ng-model="currentConfig[field.name]"
|
||||
allowed-entities="['user', 'team']"
|
||||
ng-switch-when="entity">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<span class="entity-search-element" ng-class="isPersistent ? 'persistent' : ''"><input class="entity-search-control form-control">
|
||||
<span class="entity-reference block-reference" ng-show="isPersistent && currentEntityInternal" entity="currentEntityInternal"></span>
|
||||
<span class="entity-search-element" ng-class="autoClear ? '' : 'persistent'"><input class="entity-search-control form-control">
|
||||
<span class="entity-reference block-reference" ng-show="!autoClear && currentEntityInternal" entity="currentEntityInternal"></span>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-default dropdown-toggle" type="button" id="entityDropdownMenu" data-toggle="dropdown"
|
||||
ng-click="lazyLoad()">
|
||||
|
@ -34,7 +34,7 @@
|
|||
<i class="fa fa-group"></i> Create team
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation" ng-show="!lazyLoading && isAdmin">
|
||||
<li role="presentation" ng-show="includeRobots && !lazyLoading && isAdmin">
|
||||
<a role="menuitem" class="new-action" tabindex="-1" href="javascript:void(0)" ng-click="createRobot()">
|
||||
<i class="fa fa-wrench"></i>
|
||||
Create robot account
|
||||
|
|
|
@ -83,9 +83,11 @@
|
|||
<tr ng-show="!newForWholeOrg">
|
||||
<td>Repository Creator:</td>
|
||||
<td>
|
||||
<span class="entity-search" namespace="organization.name" input-title="'User/Robot'"
|
||||
is-organization="true" include-teams="false" current-entity="activatingForNew" is-persistent="true"
|
||||
clear-now="clearCounter">
|
||||
<span class="entity-search" namespace="organization.name"
|
||||
placeholder="'User/Robot'"
|
||||
allowed-entities="['user', 'robot']"
|
||||
current-entity="activatingForNew"
|
||||
clear-value="clearCounter">
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -98,9 +100,9 @@
|
|||
<tr>
|
||||
<td>Applied To:</td>
|
||||
<td>
|
||||
<span class="entity-search" namespace="organization.name" input-title="'User/Robot/Team'"
|
||||
is-organization="true" include-teams="true" current-entity="delegateForNew" is-persistent="true"
|
||||
clear-now="clearCounter">
|
||||
<span class="entity-search" namespace="organization.name" placeholder="'User/Robot/Team'"
|
||||
current-entity="delegateForNew"
|
||||
clear-value="clearCounter">
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -71,12 +71,10 @@
|
|||
<td>
|
||||
</td>
|
||||
<td>
|
||||
<div class="entity-search" namespace="repository.namespace" include-teams="false"
|
||||
input-title="'Select robot account for pulling...'"
|
||||
is-organization="repository.is_organization"
|
||||
is-persistent="true"
|
||||
<div class="entity-search" namespace="repository.namespace"
|
||||
placeholder="'Select robot account for pulling...'"
|
||||
current-entity="pullEntity"
|
||||
filter="['robot']"></div>
|
||||
allowed-entities="['robot']"></div>
|
||||
|
||||
<div class="alert alert-info" ng-if="pullRequirements.robots.length" style="margin-top: 20px; margin-bottom: 0px;">
|
||||
Note: We've automatically selected robot account <span class="entity-reference" entity="pullRequirements.robots[0]"></span>, since it has access to the Quay.io repository.
|
||||
|
|
319
static/js/app.js
319
static/js/app.js
|
@ -999,7 +999,14 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
|||
{
|
||||
'id': 'quay_notification',
|
||||
'title': 'Quay.io notification',
|
||||
'icon': 'quay-icon'
|
||||
'icon': 'quay-icon',
|
||||
'fields': [
|
||||
{
|
||||
'name': 'target',
|
||||
'type': 'entity',
|
||||
'title': 'Recipient'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 'email',
|
||||
|
@ -3232,48 +3239,72 @@ quayApp.directive('entitySearch', function () {
|
|||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
require: '?ngModel',
|
||||
link: function(scope, element, attr, ctrl) {
|
||||
scope.ngModel = ctrl;
|
||||
},
|
||||
scope: {
|
||||
'namespace': '=namespace',
|
||||
'inputTitle': '=inputTitle',
|
||||
'entitySelected': '=entitySelected',
|
||||
'includeTeams': '=includeTeams',
|
||||
'isOrganization': '=isOrganization',
|
||||
'isPersistent': '=isPersistent',
|
||||
'placeholder': '=placeholder',
|
||||
|
||||
// Default: ['user', 'team', 'robot']
|
||||
'allowedEntities': '=allowedEntities',
|
||||
|
||||
'currentEntity': '=currentEntity',
|
||||
'clearNow': '=clearNow',
|
||||
'filter': '=filter',
|
||||
'entitySelected': '&entitySelected',
|
||||
|
||||
// When set to true, the contents of the control will be cleared as soon
|
||||
// as an entity is selected.
|
||||
'autoClear': '=autoClear',
|
||||
|
||||
// Set this property to immediately clear the contents of the control.
|
||||
'clearValue': '=clearValue',
|
||||
},
|
||||
controller: function($scope, $element, Restangular, UserService, ApiService) {
|
||||
controller: function($rootScope, $scope, $element, Restangular, UserService, ApiService) {
|
||||
$scope.lazyLoading = true;
|
||||
|
||||
$scope.teams = null;
|
||||
$scope.robots = null;
|
||||
|
||||
$scope.isAdmin = false;
|
||||
$scope.isOrganization = false;
|
||||
|
||||
$scope.includeTeams = true;
|
||||
$scope.includeRobots = true;
|
||||
|
||||
$scope.currentEntityInternal = $scope.currentEntity;
|
||||
|
||||
var isSupported = function(kind, opt_array) {
|
||||
return $.inArray(kind, opt_array || $scope.allowedEntities || ['user', 'team', 'robot']) >= 0;
|
||||
};
|
||||
|
||||
$scope.lazyLoad = function() {
|
||||
if (!$scope.namespace || !$scope.lazyLoading) { return; }
|
||||
|
||||
// Determine whether we can admin this namespace.
|
||||
$scope.isAdmin = UserService.isNamespaceAdmin($scope.namespace);
|
||||
|
||||
// If the scope is an organization and we are not part of it, then nothing more we can do.
|
||||
if (!$scope.isAdmin && $scope.isOrganization && !UserService.getOrganization($scope.namespace)) {
|
||||
$scope.teams = null;
|
||||
$scope.robots = null;
|
||||
$scope.lazyLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.isOrganization && $scope.includeTeams) {
|
||||
// Reset the cached teams and robots.
|
||||
$scope.teams = null;
|
||||
$scope.robots = null;
|
||||
|
||||
// Load the organization's teams (if applicable).
|
||||
if ($scope.isOrganization && isSupported('team')) {
|
||||
// Note: We load the org here again so that we always have the fully up-to-date
|
||||
// teams list.
|
||||
ApiService.getOrganization(null, {'orgname': $scope.namespace}).then(function(resp) {
|
||||
$scope.teams = resp.teams;
|
||||
});
|
||||
}
|
||||
|
||||
ApiService.getRobots($scope.isOrganization ? $scope.namespace : null).then(function(resp) {
|
||||
$scope.robots = resp.robots;
|
||||
// Load the user/organization's robots (if applicable).
|
||||
if ($scope.isAdmin && isSupported('robot')) {
|
||||
ApiService.getRobots($scope.isOrganization ? $scope.namespace : null).then(function(resp) {
|
||||
$scope.robots = resp.robots;
|
||||
$scope.lazyLoading = false;
|
||||
}, function() {
|
||||
$scope.lazyLoading = false;
|
||||
});
|
||||
} else {
|
||||
$scope.lazyLoading = false;
|
||||
}, function() {
|
||||
$scope.lazyLoading = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.createTeam = function() {
|
||||
|
@ -3321,7 +3352,7 @@ quayApp.directive('entitySearch', function () {
|
|||
'is_robot': is_robot
|
||||
};
|
||||
|
||||
if ($scope.is_organization) {
|
||||
if ($scope.isOrganization) {
|
||||
entity['is_org_member'] = true;
|
||||
}
|
||||
|
||||
|
@ -3331,146 +3362,184 @@ quayApp.directive('entitySearch', function () {
|
|||
$scope.clearEntityInternal = function() {
|
||||
$scope.currentEntityInternal = null;
|
||||
$scope.currentEntity = null;
|
||||
|
||||
if ($scope.entitySelected) {
|
||||
$scope.entitySelected(null);
|
||||
$scope.entitySelected({'entity': null});
|
||||
if ($scope.ngModel) {
|
||||
$scope.ngModel.$setValidity('entity', false);
|
||||
}
|
||||
};
|
||||
|
||||
$scope.setEntityInternal = function(entity, updateTypeahead) {
|
||||
if (updateTypeahead) {
|
||||
$(input).typeahead('val', $scope.isPersistent ? entity.name : '');
|
||||
$(input).typeahead('val', $scope.autoClear ? '' : entity.name);
|
||||
} else {
|
||||
$(input).val($scope.isPersistent ? entity.name : '');
|
||||
$(input).val($scope.autoClear ? '' : entity.name);
|
||||
}
|
||||
|
||||
if ($scope.isPersistent) {
|
||||
if (!$scope.autoClear) {
|
||||
$scope.currentEntityInternal = entity;
|
||||
$scope.currentEntity = entity;
|
||||
}
|
||||
|
||||
if ($scope.entitySelected) {
|
||||
$scope.entitySelected(entity);
|
||||
$scope.entitySelected({'entity': entity});
|
||||
if ($scope.ngModel) {
|
||||
$scope.ngModel.$setValidity('entity', !!entity);
|
||||
}
|
||||
};
|
||||
|
||||
number++;
|
||||
// Setup the typeahead.
|
||||
var input = $element[0].firstChild.firstChild;
|
||||
|
||||
(function() {
|
||||
// Create the bloodhound search query system.
|
||||
$rootScope.__entity_search_counter = (($rootScope.__entity_search_counter || 0) + 1);
|
||||
var entitySearchB = new Bloodhound({
|
||||
name: 'entities' + $rootScope.__entity_search_counter,
|
||||
remote: {
|
||||
url: '/api/v1/entities/%QUERY',
|
||||
replace: function (url, uriEncodedQuery) {
|
||||
var namespace = $scope.namespace || '';
|
||||
url = url.replace('%QUERY', uriEncodedQuery);
|
||||
url += '?namespace=' + encodeURIComponent(namespace);
|
||||
if ($scope.isOrganization && isSupported('team')) {
|
||||
url += '&includeTeams=true'
|
||||
}
|
||||
return url;
|
||||
},
|
||||
filter: function(data) {
|
||||
var datums = [];
|
||||
for (var i = 0; i < data.results.length; ++i) {
|
||||
var entity = data.results[i];
|
||||
|
||||
var entitySearchB = new Bloodhound({
|
||||
name: 'entities' + number,
|
||||
remote: {
|
||||
url: '/api/v1/entities/%QUERY',
|
||||
replace: function (url, uriEncodedQuery) {
|
||||
var namespace = $scope.namespace || '';
|
||||
url = url.replace('%QUERY', uriEncodedQuery);
|
||||
url += '?namespace=' + encodeURIComponent(namespace);
|
||||
if ($scope.includeTeams) {
|
||||
url += '&includeTeams=true'
|
||||
}
|
||||
return url;
|
||||
},
|
||||
filter: function(data) {
|
||||
var datums = [];
|
||||
for (var i = 0; i < data.results.length; ++i) {
|
||||
var entity = data.results[i];
|
||||
if ($scope.filter) {
|
||||
var allowed = $scope.filter;
|
||||
var found = 'user';
|
||||
if (entity.kind == 'user') {
|
||||
found = entity.is_robot ? 'robot' : 'user';
|
||||
} else if (entity.kind == 'team') {
|
||||
found = 'team';
|
||||
}
|
||||
if (allowed.indexOf(found)) {
|
||||
|
||||
if (!isSupported(found)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
datums.push({
|
||||
'value': entity.name,
|
||||
'tokens': [entity.name],
|
||||
'entity': entity
|
||||
});
|
||||
}
|
||||
return datums;
|
||||
}
|
||||
},
|
||||
datumTokenizer: function(d) {
|
||||
return Bloodhound.tokenizers.whitespace(d.val);
|
||||
},
|
||||
queryTokenizer: Bloodhound.tokenizers.whitespace
|
||||
});
|
||||
entitySearchB.initialize();
|
||||
|
||||
// Setup the typeahead.
|
||||
$(input).typeahead({
|
||||
'highlight': true
|
||||
}, {
|
||||
source: entitySearchB.ttAdapter(),
|
||||
templates: {
|
||||
'empty': function(info) {
|
||||
// Only display the empty dialog if the server load has finished.
|
||||
if (info.resultKind == 'remote') {
|
||||
var val = $(input).val();
|
||||
if (!val) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (val.indexOf('@') > 0) {
|
||||
return '<div class="tt-empty">A Quay.io username (not an e-mail address) must be specified</div>';
|
||||
}
|
||||
|
||||
var classes = [];
|
||||
|
||||
if (isSupported('user')) { classes.push('users'); }
|
||||
if ($scope.isAdmin && isSupported('robot')) { classes.push('robot accounts'); }
|
||||
if ($scope.isOrganization && isSupported('team')) { classes.push('teams'); }
|
||||
|
||||
if (classes.length > 1) {
|
||||
classes[classes.length - 1] = 'or ' + classes[classes.length - 1];
|
||||
}
|
||||
|
||||
var class_string = '';
|
||||
for (var i = 0; i < classes.length; ++i) {
|
||||
if (i > 0) {
|
||||
if (i == classes.length - 1) {
|
||||
class_string += ' or ';
|
||||
} else {
|
||||
class_string += ', ';
|
||||
}
|
||||
}
|
||||
|
||||
class_string += classes[i];
|
||||
}
|
||||
|
||||
return '<div class="tt-empty">No matching Quay.io ' + class_string + ' found</div>';
|
||||
}
|
||||
|
||||
datums.push({
|
||||
'value': entity.name,
|
||||
'tokens': [entity.name],
|
||||
'entity': entity
|
||||
});
|
||||
}
|
||||
return datums;
|
||||
}
|
||||
},
|
||||
datumTokenizer: function(d) {
|
||||
return Bloodhound.tokenizers.whitespace(d.val);
|
||||
},
|
||||
queryTokenizer: Bloodhound.tokenizers.whitespace
|
||||
});
|
||||
entitySearchB.initialize();
|
||||
return null;
|
||||
},
|
||||
'suggestion': function (datum) {
|
||||
template = '<div class="entity-mini-listing">';
|
||||
if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
|
||||
template += '<i class="fa fa-user fa-lg"></i>';
|
||||
} else if (datum.entity.kind == 'user' && datum.entity.is_robot) {
|
||||
template += '<i class="fa fa-wrench fa-lg"></i>';
|
||||
} else if (datum.entity.kind == 'team') {
|
||||
template += '<i class="fa fa-group fa-lg"></i>';
|
||||
}
|
||||
template += '<span class="name">' + datum.value + '</span>';
|
||||
|
||||
var counter = 0;
|
||||
var input = $element[0].firstChild.firstChild;
|
||||
$(input).typeahead({
|
||||
'highlight': true
|
||||
}, {
|
||||
source: entitySearchB.ttAdapter(),
|
||||
templates: {
|
||||
'empty': function(info) {
|
||||
// Only display the empty dialog if the server load has finished.
|
||||
if (info.resultKind == 'remote') {
|
||||
var val = $(input).val();
|
||||
if (!val) {
|
||||
return null;
|
||||
if (datum.entity.is_org_member === false && datum.entity.kind == 'user') {
|
||||
template += '<i class="fa fa-exclamation-triangle" title="User is outside the organization"></i>';
|
||||
}
|
||||
|
||||
if (val.indexOf('@') > 0) {
|
||||
return '<div class="tt-empty">A Quay.io username (not an e-mail address) must be specified</div>';
|
||||
}
|
||||
template += '</div>';
|
||||
return template;
|
||||
}}
|
||||
});
|
||||
|
||||
var robots = $scope.isOrganization ? ', robot accounts' : '';
|
||||
var teams = ($scope.includeTeams && $scope.isOrganization) ? ' or teams' : '';
|
||||
return '<div class="tt-empty">No matching Quay.io users' + robots + teams + ' found</div>';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
'suggestion': function (datum) {
|
||||
template = '<div class="entity-mini-listing">';
|
||||
if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
|
||||
template += '<i class="fa fa-user fa-lg"></i>';
|
||||
} else if (datum.entity.kind == 'user' && datum.entity.is_robot) {
|
||||
template += '<i class="fa fa-wrench fa-lg"></i>';
|
||||
} else if (datum.entity.kind == 'team') {
|
||||
template += '<i class="fa fa-group fa-lg"></i>';
|
||||
}
|
||||
template += '<span class="name">' + datum.value + '</span>';
|
||||
|
||||
if (datum.entity.is_org_member === false && datum.entity.kind == 'user') {
|
||||
template += '<i class="fa fa-exclamation-triangle" title="User is outside the organization"></i>';
|
||||
}
|
||||
|
||||
template += '</div>';
|
||||
return template;
|
||||
}}
|
||||
});
|
||||
|
||||
$(input).on('input', function(e) {
|
||||
$scope.$apply(function() {
|
||||
if ($scope.isPersistent) {
|
||||
$(input).on('input', function(e) {
|
||||
$scope.$apply(function() {
|
||||
$scope.clearEntityInternal();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$(input).on('typeahead:selected', function(e, datum) {
|
||||
$scope.$apply(function() {
|
||||
$scope.setEntityInternal(datum.entity, true);
|
||||
$(input).on('typeahead:selected', function(e, datum) {
|
||||
$scope.$apply(function() {
|
||||
$scope.setEntityInternal(datum.entity, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
$scope.$watch('clearValue', function() {
|
||||
if (!input) { return; }
|
||||
|
||||
$scope.$watch('clearNow', function() {
|
||||
$(input).typeahead('val', '');
|
||||
$scope.clearEntityInternal();
|
||||
});
|
||||
|
||||
$scope.$watch('inputTitle', function(title) {
|
||||
$scope.$watch('placeholder', function(title) {
|
||||
input.setAttribute('placeholder', title);
|
||||
});
|
||||
|
||||
$scope.$watch('allowedEntities', function(allowed) {
|
||||
if (!allowed) { return; }
|
||||
$scope.includeTeams = isSupported('team', allowed);
|
||||
$scope.includeRobots = isSupported('robot', allowed);
|
||||
});
|
||||
|
||||
$scope.$watch('namespace', function(namespace) {
|
||||
if (!namespace) { return; }
|
||||
|
||||
$scope.isAdmin = UserService.isNamespaceAdmin(namespace);
|
||||
$scope.isOrganization = !!UserService.getOrganization(namespace);
|
||||
});
|
||||
|
||||
$scope.$watch('currentEntity', function(entity) {
|
||||
if ($scope.currentEntityInternal != entity) {
|
||||
if (entity) {
|
||||
|
|
|
@ -135,10 +135,11 @@
|
|||
|
||||
<tr>
|
||||
<td id="add-entity-permission" colspan="2" class="admin-search">
|
||||
<span class="entity-search" namespace="repo.namespace" include-teams="true"
|
||||
input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'"
|
||||
entity-selected="addNewPermission" is-organization="repo.is_organization"
|
||||
current-entity="selectedEntity"></span>
|
||||
<span class="entity-search" namespace="repo.namespace"
|
||||
placeholder="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'"
|
||||
entity-selected="addNewPermission(entity)"
|
||||
current-entity="selectedEntity"
|
||||
auto-clear="true"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -23,10 +23,13 @@
|
|||
</tr>
|
||||
|
||||
<tr ng-show="canEditMembers">
|
||||
<td colspan="2">
|
||||
<span class="entity-search" namespace="orgname" include-teams="false" input-title="'Add a Quay.io user...'"
|
||||
entity-selected="addNewMember" is-organization="true"
|
||||
current-entity="selectedMember"></span>
|
||||
<td colspan="3">
|
||||
<div class="entity-search" style="width: 100%"
|
||||
namespace="orgname" placeholder="'Add a Quay.io user or robot...'"
|
||||
entity-selected="addNewMember(entity)"
|
||||
current-entity="selectedMember"
|
||||
auto-clear="true"
|
||||
allowed-entities="['user', 'robot']"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
Reference in a new issue