You've already forked oncall
mirror of
https://github.com/linkedin/oncall.git
synced 2025-11-26 23:10:47 +02:00
Add team pinning
Allow users to pin teams to landing page. Persist data in db to keep view consistent across machines
This commit is contained in:
committed by
Qingping Hou
parent
6fb39e2105
commit
05e815a7a7
@@ -29,6 +29,26 @@ CREATE TABLE IF NOT EXISTS `user` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `username_unique` (`name` ASC));
|
||||
|
||||
-- -----------------------------------------------------
|
||||
-- Table `pinned_team`
|
||||
-- -----------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `pinned_team` (
|
||||
`team_id` BIGINT(20) UNSIGNED NOT NULL,
|
||||
`user_id` BIGINT(20) UNSIGNED NOT NULL,
|
||||
INDEX `team_member_team_id_idx` (`team_id` ASC),
|
||||
INDEX `team_member_user_id_idx` (`user_id` ASC),
|
||||
PRIMARY KEY (`team_id`, `user_id`),
|
||||
CONSTRAINT `pinned_team_team_id_fk`
|
||||
FOREIGN KEY (`team_id`)
|
||||
REFERENCES `team` (`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT `pinned_team_user_id_fk`
|
||||
FOREIGN KEY (`user_id`)
|
||||
REFERENCES `user` (`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE);
|
||||
|
||||
-- -----------------------------------------------------
|
||||
-- Table `team_user`
|
||||
-- -----------------------------------------------------
|
||||
|
||||
65
e2e/test_pin.py
Normal file
65
e2e/test_pin.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import requests
|
||||
from testutils import prefix, api_v0
|
||||
|
||||
|
||||
@prefix('test_v0_pin_team')
|
||||
def test_v0_pin_team(user, team):
|
||||
user_name = user.create()
|
||||
team_name = team.create()
|
||||
team_name_2 = team.create()
|
||||
|
||||
# Test pinning teams
|
||||
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
|
||||
json={'team': team_name})
|
||||
assert re.status_code == 201
|
||||
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
|
||||
json={'team': team_name_2})
|
||||
assert re.status_code == 201
|
||||
|
||||
# Test getting pinned teams
|
||||
re = requests.get(api_v0('users/%s/pinned_teams' % user_name))
|
||||
assert re.status_code == 200
|
||||
data = re.json()
|
||||
assert len(data) == 2
|
||||
assert team_name in data
|
||||
assert team_name_2 in data
|
||||
|
||||
# Test deleting pinned teams
|
||||
re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name)))
|
||||
assert re.status_code == 200
|
||||
|
||||
re = requests.get(api_v0('users/%s/pinned_teams' % user_name))
|
||||
assert re.status_code == 200
|
||||
data = re.json()
|
||||
assert len(data) == 1
|
||||
assert team_name not in data
|
||||
|
||||
|
||||
@prefix('test_v0_pin_invalid')
|
||||
def test_api_v0_pin_invalid(user, team):
|
||||
user_name = user.create()
|
||||
team_name = team.create()
|
||||
|
||||
# Test pinning duplicate team
|
||||
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
|
||||
json={'team': team_name})
|
||||
assert re.status_code == 201
|
||||
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
|
||||
json={'team': team_name})
|
||||
assert re.status_code == 400
|
||||
|
||||
# Test pinning nonexistent team
|
||||
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
|
||||
json={'team': 'nonexistent-team-foobar'})
|
||||
assert re.status_code == 422
|
||||
|
||||
# Test pinning team for nonexistent user
|
||||
re = requests.post(api_v0('users/%s/pinned_teams' % 'nonexistent-user-foobar'),
|
||||
json={'team': team_name})
|
||||
assert re.status_code == 422
|
||||
|
||||
# Test deleting unpinned team
|
||||
re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name)))
|
||||
assert re.status_code == 200
|
||||
re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name)))
|
||||
assert re.status_code == 404
|
||||
@@ -72,6 +72,10 @@ def init(application, config):
|
||||
from . import upcoming_shifts
|
||||
application.add_route('/api/v0/users/{user_name}/upcoming', upcoming_shifts)
|
||||
|
||||
from . import user_pinned_teams, user_pinned_team
|
||||
application.add_route('/api/v0/users/{user_name}/pinned_teams', user_pinned_teams)
|
||||
application.add_route('/api/v0/users/{user_name}/pinned_teams/{team_name}', user_pinned_team)
|
||||
|
||||
# Optional Iris integration
|
||||
from . import iris_settings
|
||||
application.add_route('/api/v0/iris_settings', iris_settings)
|
||||
|
||||
@@ -171,6 +171,7 @@ def on_put(req, resp, event_id):
|
||||
def on_delete(req, resp, event_id):
|
||||
"""
|
||||
Delete an event by id, anyone on the team can delete that team's events
|
||||
|
||||
**Example request:**
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
34
src/oncall/api/v0/user_pinned_team.py
Normal file
34
src/oncall/api/v0/user_pinned_team.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from ... import db
|
||||
from ...auth import login_required, check_user_auth
|
||||
from falcon import HTTPNotFound
|
||||
|
||||
|
||||
@login_required
|
||||
def on_delete(req, resp, user_name, team_name):
|
||||
'''
|
||||
Delete a pinned team
|
||||
|
||||
**Example request:**
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v0/users/jdoe/pinned_teams/team-foo HTTP/1.1
|
||||
|
||||
:statuscode 200: Successful delete
|
||||
:statuscode 403: Delete not allowed; logged in user does not match user_name
|
||||
:statuscode 404: Team not found in user's pinned teams
|
||||
'''
|
||||
check_user_auth(user_name, req)
|
||||
connection = db.connect()
|
||||
cursor = connection.cursor()
|
||||
cursor.execute('''DELETE FROM `pinned_team`
|
||||
WHERE `user_id` = (SELECT `id` FROM `user` WHERE `name` = %s)
|
||||
AND `team_id` = (SELECT `id` FROM `team` WHERE `name` = %s)''',
|
||||
(user_name, team_name))
|
||||
deleted = cursor.rowcount
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
if deleted == 0:
|
||||
raise HTTPNotFound()
|
||||
|
||||
89
src/oncall/api/v0/user_pinned_teams.py
Normal file
89
src/oncall/api/v0/user_pinned_teams.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from ... import db
|
||||
from ...utils import load_json_body
|
||||
from ...auth import login_required, check_user_auth
|
||||
from ujson import dumps as json_dumps
|
||||
from falcon import HTTPBadRequest, HTTP_201, HTTPError
|
||||
|
||||
|
||||
def on_get(req, resp, user_name):
|
||||
'''
|
||||
Get all pinned team names for a user
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v0/users/jdoe/pinned_teams HTTP/1.1
|
||||
Host: example.com
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
"team-foo"
|
||||
]
|
||||
'''
|
||||
connection = db.connect()
|
||||
cursor = connection.cursor()
|
||||
cursor.execute('''SELECT `team`.`name`
|
||||
FROM `pinned_team` JOIN `team` ON `pinned_team`.`team_id` = `team`.`id`
|
||||
WHERE `pinned_team`.`user_id` = (SELECT `id` FROM `user` WHERE `name` = %s)''',
|
||||
user_name)
|
||||
teams = [r[0] for r in cursor]
|
||||
cursor.close()
|
||||
connection.close()
|
||||
resp.body = json_dumps(teams)
|
||||
|
||||
|
||||
@login_required
|
||||
def on_post(req, resp, user_name):
|
||||
'''
|
||||
Pin a team to the landing page for a user
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v0/users/jdoe/pinned_teams HTTP/1.1
|
||||
Host: example.com
|
||||
|
||||
{
|
||||
"team": "team-foo"
|
||||
}
|
||||
|
||||
:statuscode 201: Successful team pin
|
||||
:statuscode 400: Missing team parameter or team already pinned
|
||||
'''
|
||||
check_user_auth(user_name, req)
|
||||
data = load_json_body(req)
|
||||
team = data.get('team')
|
||||
if team is None:
|
||||
raise HTTPBadRequest('Invalid team pin', 'Missing team parameter')
|
||||
connection = db.connect()
|
||||
cursor = connection.cursor()
|
||||
try:
|
||||
cursor.execute('''INSERT INTO `pinned_team` (`user_id`, `team_id`)
|
||||
VALUES ((SELECT `id` FROM `user` WHERE `name` = %s),
|
||||
(SELECT `id` FROM `team` WHERE `name` = %s))''',
|
||||
(user_name, team))
|
||||
connection.commit()
|
||||
except db.IntegrityError as e:
|
||||
# Duplicate key
|
||||
if e.args[0] == 1062:
|
||||
raise HTTPBadRequest('Invalid team pin', 'Team already pinned for this user')
|
||||
# Team/user is null
|
||||
elif e.args[0] == 1048:
|
||||
err_msg = str(e.args[1])
|
||||
if err_msg == 'Column \'user_id\' cannot be null':
|
||||
err_msg = 'user "%s" not found' % user_name
|
||||
elif err_msg == 'Column \'team_id\' cannot be null':
|
||||
err_msg = 'team "%s" not found' % team
|
||||
raise HTTPError('422 Unprocessable Entity', 'IntegrityError', err_msg)
|
||||
finally:
|
||||
cursor.close()
|
||||
connection.close()
|
||||
resp.status = HTTP_201
|
||||
@@ -537,13 +537,22 @@ nav.subnav li.active {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.recently-viewed h3 {
|
||||
body[data-authenticated="false"] #pinned-teams {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.landing-teams {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.landing-teams h3 {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #dddedf;
|
||||
}
|
||||
|
||||
.module-card .recently-viewed-name {
|
||||
.module-card .landing-teams-name {
|
||||
width: calc(100% - 60px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -759,6 +768,10 @@ nav.subnav li.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body[data-authenticated="false"] #pin-team {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.team-name-input {
|
||||
background: none;
|
||||
color: #FFF;
|
||||
|
||||
@@ -411,10 +411,12 @@ var oncall = {
|
||||
summaryUrl: '/api/v0/teams/',
|
||||
pageSource: $('#search-template').html(),
|
||||
searchResultsSource: $('#search-results-template').html(),
|
||||
cardInnerTemplate: $('#recently-viewed-inner-template').html(),
|
||||
cardInnerTemplate: $('#landing-teams-inner-template').html(),
|
||||
endpointTypes: ['services', 'teams'],
|
||||
searchForm: '.main-search',
|
||||
recentlyViewed: null
|
||||
recentlyViewed: null,
|
||||
pinnedTeams: null,
|
||||
pinnedPromise: $.Deferred()
|
||||
},
|
||||
init: function(query){
|
||||
var $form,
|
||||
@@ -427,15 +429,27 @@ var oncall = {
|
||||
self = this;
|
||||
|
||||
Handlebars.registerPartial('dashboard-card-inner', this.data.cardInnerTemplate);
|
||||
oncall.callbacks.onLogin = function(){
|
||||
self.init();
|
||||
};
|
||||
this.data.recentlyViewed = oncall.recentlyViewed.getItems();
|
||||
this.renderPage();
|
||||
this.getTeamSummaries();
|
||||
if (oncall.data.user) {
|
||||
$.get('/api/v0/users/' + oncall.data.user + '/pinned_teams').done(function(response){
|
||||
self.data.pinnedTeams = response;
|
||||
self.data.pinnedPromise.resolve();
|
||||
})
|
||||
} else {
|
||||
this.data.pinnedPromise.resolve();
|
||||
}
|
||||
|
||||
$form = this.data.$page.find(this.data.searchForm);
|
||||
this.data.pinnedPromise.done(function() {
|
||||
self.renderPage();
|
||||
self.getTeamSummaries();
|
||||
$form = self.data.$page.find(self.data.searchForm);
|
||||
$input = $form.find('.search-input');
|
||||
|
||||
if (query) {
|
||||
this.getData.call(this, query);
|
||||
self.getData.call(self, query);
|
||||
$form.find('.search-input').val(decodeURIComponent(query.query));
|
||||
}
|
||||
|
||||
@@ -443,7 +457,7 @@ var oncall = {
|
||||
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
|
||||
queryTokenizer: Bloodhound.tokenizers.whitespace,
|
||||
remote: {
|
||||
url: this.data.url + '?keyword=%QUERY',
|
||||
url: self.data.url + '?keyword=%QUERY',
|
||||
rateLimitWait: 200,
|
||||
wildcard: '%QUERY',
|
||||
transform: function(resp){
|
||||
@@ -466,7 +480,7 @@ var oncall = {
|
||||
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
|
||||
queryTokenizer: Bloodhound.tokenizers.whitespace,
|
||||
remote: {
|
||||
url: this.data.url + '?keyword=%QUERY',
|
||||
url: self.data.url + '?keyword=%QUERY',
|
||||
rateLimitWait: 200,
|
||||
wildcard: '%QUERY',
|
||||
transform: function(resp){
|
||||
@@ -539,6 +553,7 @@ var oncall = {
|
||||
.on('typeahead:selected', function(){
|
||||
router.navigate('/team/' + $(this).val());
|
||||
});
|
||||
});
|
||||
},
|
||||
events: function(){
|
||||
this.data.$page.on('submit', this.data.searchForm, this.updateSearch.bind(this));
|
||||
@@ -555,7 +570,7 @@ var oncall = {
|
||||
$.get(this.data.url, param, this.renderResults.bind(this));
|
||||
},
|
||||
getTeamSummaries: function(){
|
||||
var data = this.data.recentlyViewed,
|
||||
var data = this.data.pinnedTeams ? this.data.recentlyViewed.concat(this.data.pinnedTeams) : this.data.recentlyViewed,
|
||||
self = this;
|
||||
if (data) {
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
@@ -581,7 +596,31 @@ var oncall = {
|
||||
},
|
||||
renderPage: function(){
|
||||
var template = Handlebars.compile(this.data.pageSource);
|
||||
this.data.$page.html(template(this.data.recentlyViewed));
|
||||
this.data.$page.html(template({recent: this.data.recentlyViewed, pinned: this.data.pinnedTeams}));
|
||||
|
||||
this.data.$page.on('click','.remove-card-column', function(){
|
||||
var $teamCard = $(this).closest('.module-card'),
|
||||
$pinnedTeams = $('#pinned-teams'),
|
||||
teamName = $teamCard.attr('data-team');
|
||||
|
||||
oncall.data.$body.addClass('loading-view');
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: 'api/v0/users/' + oncall.data.user + '/pinned_teams/' + teamName,
|
||||
dataType: 'html'
|
||||
}).done(function(){
|
||||
$teamCard.hide();
|
||||
if ($teamCard.siblings(':visible').length === 0) {
|
||||
$pinnedTeams.hide()
|
||||
}
|
||||
}).fail(function(data){
|
||||
var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Could not unpin team.';
|
||||
oncall.alerts.createAlert('Failed: ' + error, 'danger');
|
||||
}).always(function(){
|
||||
oncall.data.$body.removeClass('loading-view');
|
||||
});
|
||||
|
||||
});
|
||||
this.events();
|
||||
},
|
||||
renderCardInner: function(data){
|
||||
@@ -612,7 +651,9 @@ var oncall = {
|
||||
team: {
|
||||
data: {
|
||||
$page: $('.content-wrapper'),
|
||||
$pinButton: $('#pin-team'),
|
||||
url: '/api/v0/teams/',
|
||||
pinUrl: '/api/v0/users/',
|
||||
teamSubheaderTemplate: $('#team-subheader-template').html(),
|
||||
subheaderWrapper: '.subheader-wrapper',
|
||||
deleteTeam: '#delete-team',
|
||||
@@ -737,6 +778,28 @@ var oncall = {
|
||||
$cta.removeClass('loading disabled').prop('disabled', false);
|
||||
});
|
||||
},
|
||||
pinTeam: function($modal) {
|
||||
var $cta = $modal.find('.modal-cta'),
|
||||
self = this;
|
||||
|
||||
$cta.addClass('loading disabled').prop('disabled', true);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: self.data.pinUrl + oncall.data.user + '/pinned_teams/',
|
||||
contentType: 'application/json',
|
||||
dataType: 'html',
|
||||
data: JSON.stringify({team:self.data.teamName})
|
||||
}).done(function(){
|
||||
oncall.alerts.removeAlerts();
|
||||
oncall.alerts.createAlert('Pinned team to home page', 'success');
|
||||
}).fail(function(data){
|
||||
var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Pinning team failed.';
|
||||
oncall.alerts.createAlert(error, 'danger');
|
||||
}).always(function(){
|
||||
$cta.removeClass('loading disabled').prop('disabled', false);
|
||||
$modal.modal('hide');
|
||||
});
|
||||
},
|
||||
calendar: {
|
||||
data: {
|
||||
$page: $('.content-wrapper'),
|
||||
|
||||
@@ -129,11 +129,39 @@
|
||||
<div class="main container-fluid">
|
||||
<ul class="search-results">
|
||||
</ul>
|
||||
{{#if this}}
|
||||
<div class="recently-viewed">
|
||||
{{#if this.pinned}}
|
||||
<div class="landing-teams" id="pinned-teams">
|
||||
<h3>Pinned Teams</h3>
|
||||
<div class="card-wrap col-3">
|
||||
{{#each this.pinned}}
|
||||
<div class="module module-card dashboard-card card-column" data-team="{{this}}">
|
||||
<div class="module-card-heading border-bottom">
|
||||
<h4>
|
||||
<span>
|
||||
<a href="/team/{{this}}" data-navigo>{{this}}</a>
|
||||
</span>
|
||||
</h4>
|
||||
<span class="remove-card-column">
|
||||
<i class="svg-icon grey-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
|
||||
<path d="M1.41 0l-1.41 1.41.72.72 1.78 1.81-1.78 1.78-.72.69 1.41 1.44.72-.72 1.81-1.81 1.78 1.81.69.72 1.44-1.44-.72-.69-1.81-1.78 1.81-1.81.72-.72-1.44-1.41-.69.72-1.78 1.78-1.81-1.78-.72-.72z" />
|
||||
</svg>
|
||||
</i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="dashboard-card-inner">
|
||||
<i class="loader"></i>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.recent}}
|
||||
<div class="landing-teams" id="recent-teams">
|
||||
<h3>Most Recently Viewed</h3>
|
||||
<div class="card-wrap col-3">
|
||||
{{#each .}}
|
||||
{{#each this.recent}}
|
||||
<div class="module module-card dashboard-card card-column" data-team="{{this}}">
|
||||
<div class="module-card-heading border-bottom">
|
||||
<h4>
|
||||
@@ -199,14 +227,14 @@
|
||||
{{/if}}
|
||||
</script>
|
||||
|
||||
<!--// recently viewed partial -->
|
||||
<script id="recently-viewed-inner-template" type="text/x-handlebars-template">
|
||||
<!--// landing teams partial -->
|
||||
<script id="landing-teams-inner-template" type="text/x-handlebars-template">
|
||||
<ul class="card-inner-wrapper">
|
||||
{{#ifNotEmpty data.current.primary}}
|
||||
<span class="subheading light">On Call Now</span>
|
||||
<li class="card-inner border-bottom" data-card-name="{{data.current.primary.0.full_name}}">
|
||||
<img class="card-picture" src="{{data.current.primary.0.photo_url}}" alt="{{data.current.primary.0.full_name}}">
|
||||
<h4><strong><a class="recently-viewed-name" target="_blank" href="mailto:{{data.current.primary.0.user_contacts.email}}">{{data.current.primary.0.full_name}}</a></strong> <span class="badge pull-right" data-role="{{data.current.primary.0.role}}">{{data.current.primary.0.role}}</span></h4>
|
||||
<h4><strong><a class="landing-teams-name" target="_blank" href="mailto:{{data.current.primary.0.user_contacts.email}}">{{data.current.primary.0.full_name}}</a></strong> <span class="badge pull-right" data-role="{{data.current.primary.0.role}}">{{data.current.primary.0.role}}</span></h4>
|
||||
|
||||
{{#if data.current.primary.0.user_contacts.call}}
|
||||
<span class="light"><a href="tel:{{data.current.primary.0.user_contacts.call}}">{{data.current.primary.0.user_contacts.call}}</a></span>
|
||||
@@ -262,11 +290,16 @@
|
||||
</h4>
|
||||
{% endraw %} {% endif %} {% raw %}
|
||||
</div>
|
||||
<div class="pull-right" data-admin-action="true">
|
||||
<div class="pull-right">
|
||||
<span data-admin-action="true">
|
||||
{{#isEqual page "info"}}
|
||||
<button class="btn btn-white add-roster-btn" data-toggle="modal" data-target="#input-modal" data-modal-action="oncall.team.info.addRoster" data-modal-title="Add roster" data-modal-placeholder="Roster Name"> + add roster </button>
|
||||
<button id="#delete-team" class="btn btn-primary" data-toggle="modal" data-target="#confirm-action-modal" data-modal-action="oncall.team.deleteTeam" data-modal-title="Delete {{name}}" data-modal-content="Delete {{name}}?"> Delete Team </button>
|
||||
<button id="#delete-team" class="btn btn-white" data-toggle="modal" data-target="#confirm-action-modal" data-modal-action="oncall.team.deleteTeam" data-modal-title="Delete {{name}}" data-modal-content="Delete {{name}}?"> Delete team </button>
|
||||
<button class="btn btn-white add-roster-btn" data-toggle="modal" data-target="#input-modal" data-modal-action="oncall.team.info.addRoster" data-modal-title="Add roster" data-modal-placeholder="Roster Name"> Add roster </button>
|
||||
{{/isEqual}}
|
||||
</span>
|
||||
<span>
|
||||
<button id="pin-team" class="btn btn-white" data-toggle="modal" data-target="#confirm-action-modal" data-modal-action="oncall.team.pinTeam" data-modal-title="Pin {{name}}" data-modal-content="Pin {{name}} to landing page?"> Pin to home </button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user