PolyGerrit: Implement support for members tab in groups

Change-Id: I928dbeaa8ee1e652442e618380a4015a6b1230a1
This commit is contained in:
Paladox none
2017-08-04 16:27:20 +00:00
committed by Paladox
parent 05b424c6c4
commit e28b73e276
12 changed files with 686 additions and 24 deletions

View File

@@ -29,6 +29,7 @@ limitations under the License.
<link rel="import" href="../gr-admin-group-list/gr-admin-group-list.html">
<link rel="import" href="../gr-admin-project-list/gr-admin-project-list.html">
<link rel="import" href="../gr-group/gr-group.html">
<link rel="import" href="../gr-group-members/gr-group-members.html">
<link rel="import" href="../gr-plugin-list/gr-plugin-list.html">
<link rel="import" href="../gr-project/gr-project.html">
<link rel="import" href="../gr-group-audit-log/gr-group-audit-log.html">
@@ -88,6 +89,12 @@ limitations under the License.
on-name-changed="_updateGroupName"></gr-group>
</main>
</template>
<template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
<main>
<gr-group-members
group-id="[[params.groupId]]"></gr-group-members>
</main>
</template>
<template is="dom-if" if="[[_showGroupList]]" restamp="true">
<main class="table">
<gr-admin-group-list class="table" params="[[params]]">

View File

@@ -63,6 +63,7 @@
_showGroup: Boolean,
_showGroupAuditLog: Boolean,
_showGroupList: Boolean,
_showGroupMembers: Boolean,
_showProjectMain: Boolean,
_showProjectList: Boolean,
_showProjectDetailList: Boolean,
@@ -128,7 +129,15 @@
name: this._groupName,
view: 'gr-group',
url: `/admin/groups/${this.encodeURL(this._groupId + '', true)}`,
children: [],
children: [
{
name: 'Members',
detailType: 'members',
view: 'gr-group-members',
url: `/admin/groups/${this.encodeURL(this._groupId, true)}` +
',members',
},
],
};
if (this._groupOwner) {
linkCopy.subsection.children.push(
@@ -161,6 +170,7 @@
this.set('_showGroup', params.adminView === 'gr-group');
this.set('_showGroupAuditLog', params.adminView === 'gr-group-audit-log');
this.set('_showGroupList', params.adminView === 'gr-admin-group-list');
this.set('_showGroupMembers', params.adminView === 'gr-group-members');
this.set('_showProjectMain', params.adminView === 'gr-project');
this.set('_showProjectList',
params.adminView === 'gr-admin-project-list');
@@ -217,8 +227,8 @@
this._groupName = group.name;
this.reload();
this.$.restAPI.getIsGroupOwner(group.name).then(
configs => {
if (configs.hasOwnProperty(group.name)) {
isOwner => {
if (isOwner) {
this._groupOwner = true;
this.reload();
}

View File

@@ -30,6 +30,10 @@
Gerrit.ListViewBehavior,
],
attached() {
this.fire('title-change', {title: 'Audit Log'});
},
ready() {
this._getAuditLogs();
},

View File

@@ -0,0 +1,192 @@
<!--
Copyright (C) 2017 The Android Open Source Project
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.
-->
<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
<link rel="import" href="../../../bower_components/polymer/polymer.html">
<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
<link rel="import" href="../../../styles/gr-form-styles.html">
<link rel="import" href="../../../styles/shared-styles.html">
<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
<link rel="import" href="../../shared/gr-button/gr-button.html">
<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
<link rel="import" href="../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
<dom-module id="gr-group-members">
<template>
<style include="gr-form-styles"></style>
<style include="shared-styles">
main {
margin: 2em 1em;
}
.loading {
display: none;
}
#loading.loading {
display: block;
}
#loading:not(.loading) {
display: none;
}
.input {
width: 15em;
}
gr-autocomplete {
width: 20em;
--gr-autocomplete: {
font-size: 1em;
height: 2em;
width: 20em;
}
}
a {
color: var(--default-text-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
th {
border-bottom: 1px solid #eee;
font-weight: bold;
text-align: left;
}
</style>
<style include="gr-form-styles"></style>
<main class="gr-form-styles">
<div id="loading" class$="[[_computeLoadingClass(_loading)]]">
Loading...
</div>
<div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
<h1 id="Title">[[_groupName]]</h1>
<div id="form">
<fieldset>
<h3 id="members">Members</h3>
<fieldset>
<span class="value">
<gr-autocomplete
id="groupMemberSearchInput"
text="{{_groupMemberSearch}}"
query="[[_queryMembers]]"
placeholder="Name Or Email"
hidden$="[[!_groupOwner]]">
</gr-autocomplete>
</span>
<gr-button
id="saveGroupMember"
on-tap="_handleSavingGroupMember"
disabled="[[!_groupMemberSearch]]"
hidden$="[[!_groupOwner]]">
Add
</gr-button>
<div class="gr-form-styles">
<table id="groupMembers" class="gr-form-styles">
<tr class="headerRow">
<th class="nameHeader">Name</th>
<th class="emailAddressHeader">Email Address</th>
<th class="deleteHeader" hidden$="[[!_groupOwner]]">
Delete Member
</th>
</tr>
<tbody>
<template is="dom-repeat" items="[[_groupMembers]]">
<tr>
<td class="nameColumn">
<a href$="[[_memberUrl(item)]]">[[item.name]]</a>
</td>
<td>[[item.email]]</td>
<td hidden$="[[!_groupOwner]]">
<gr-button
class="deleteButton"
on-tap="_handleDeleteMember">
Delete
</gr-button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</fieldset>
</fieldset>
<fieldset>
<h3 id="includedGroups">Included Groups</h3>
<fieldset>
<span class="value">
<gr-autocomplete
id="includedGroupSearchInput"
text="{{_includedGroupSearch}}"
query="[[_queryIncludedGroup]]"
placeholder="Group Name"
hidden$="[[!_groupOwner]]">
</gr-autocomplete>
</span>
<gr-button
id="saveIncludedGroups"
on-tap="_handleSavingIncludedGroups"
disabled="[[!_includedGroupSearch]]"
hidden$="[[!_groupOwner]]">
Add
</gr-button>
<div class="gr-form-styles">
<table id="includedGroups" class="gr-form-styles">
<tr class="headerRow">
<th class="groupNameHeader">Group Name</th>
<th class="descriptionHeader">Description</th>
<th class="deleteIncludedHeader" hidden$="[[!_groupOwner]]">
Delete Included Group
</th>
</tr>
<tbody>
<template is="dom-repeat" items="[[_includedGroups]]">
<tr>
<td class="nameColumn">
<a href$="[[_groupUrl(item.group_id)]]">
[[item.name]]
</a>
</td>
<td>[[item.description]]</td>
<td hidden$="[[!_groupOwner]]">
<gr-button
class="deleteIncludedGroupButton"
on-tap="_handleDeleteIncludedGroup">
Delete
</gr-button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</fieldset>
</fieldset>
</div>
</div>
</main>
<gr-overlay id="overlay" with-backdrop>
<gr-confirm-delete-item-dialog
class="confirmDialog"
on-confirm="_handleDeleteConfirm"
on-cancel="_handleConfirmDialogCancel"
item="[[_itemName]]"
item-type="[[_itemType]]"></gr-confirm-delete-item-dialog>
</gr-overlay>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
</template>
<script src="gr-group-members.js"></script>
</dom-module>

View File

@@ -0,0 +1,239 @@
// Copyright (C) 2017 The Android Open Source Project
//
// 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.
(function() {
'use strict';
const SUGGESTIONS_LIMIT = 15;
Polymer({
is: 'gr-group-members',
properties: {
groupId: Number,
_groupMemberSearch: String,
_includedGroupSearch: String,
_loading: {
type: Boolean,
value: true,
},
_groupName: String,
_groupMembers: Object,
_includedGroups: Object,
_itemName: String,
_itemType: String,
_queryMembers: {
type: Function,
value() {
return this._getAccountSuggestions.bind(this);
},
},
_queryIncludedGroup: {
type: Function,
value() {
return this._getGroupSuggestions.bind(this);
},
},
_groupOwner: {
type: Boolean,
value: false,
},
},
behaviors: [
Gerrit.BaseUrlBehavior,
Gerrit.URLEncodingBehavior,
],
attached() {
this._loadGroupDetails();
this.fire('title-change', {title: 'Members'});
},
_loadGroupDetails() {
if (!this.groupId) { return; }
const promises = [];
return this.$.restAPI.getGroupConfig(this.groupId).then(
config => {
this._groupName = config.name;
promises.push(this.$.restAPI.getIsGroupOwner(config.name)
.then(isOwner => { this._groupOwner = isOwner; }));
promises.push(this.$.restAPI.getGroupMembers(config.name).then(
members => {
this._groupMembers = members;
}));
promises.push(this.$.restAPI.getIncludedGroup(config.name)
.then(includedGroup => {
this._includedGroups = includedGroup;
}));
return Promise.all(promises).then(() => {
this._loading = false;
});
});
},
_computeLoadingClass(loading) {
return loading ? 'loading' : '';
},
_isLoading() {
return this._loading || this._loading === undefined;
},
_memberUrl(item) {
if (item.email) {
item = item.email;
} else if (item.username) {
item = item.username;
} else {
item = item.name;
}
return this.getBaseUrl() + '/q/owner:' + this.encodeURL(item, true) +
' status:open';
},
_groupUrl(item) {
return this.getBaseUrl() + '/admin/groups/' + this.encodeURL(item, true);
},
_handleSavingGroupMember() {
return this.$.restAPI.saveGroupMembers(this._groupName,
this._groupMemberSearch).then(config => {
if (!config) {
return;
}
this.$.restAPI.getGroupMembers(this._groupName).then(members => {
this._groupMembers = members;
});
this._groupMemberSearch = '';
});
},
_handleDeleteConfirm() {
this.$.overlay.close();
if (this._itemType === 'member') {
return this.$.restAPI.deleteGroupMembers(this._groupName,
this._itemName)
.then(itemDeleted => {
if (itemDeleted.status === 204) {
this.$.restAPI.getGroupMembers(this._groupName)
.then(members => {
this._groupMembers = members;
});
}
});
} else if (this._itemType === 'includedGroup') {
return this.$.restAPI.deleteIncludedGroup(this._groupName,
this._itemName)
.then(itemDeleted => {
if (itemDeleted.status === 204) {
this.$.restAPI.getIncludedGroup(this._groupName)
.then(includedGroup => {
this._includedGroups = includedGroup;
});
}
});
}
},
_handleConfirmDialogCancel() {
this.$.overlay.close();
},
_handleDeleteMember(e) {
let item;
const name = e.model.get('item.name');
const username = e.model.get('item.username');
const email = e.model.get('item.email');
if (username) {
item = username;
} else if (name) {
item = name;
} else if (email) {
item = email;
}
if (!item) {
return '';
}
this._itemName = item;
this._itemType = 'member';
this.$.overlay.open();
},
_handleSavingIncludedGroups() {
return this.$.restAPI.saveIncludedGroup(this._groupName,
this._includedGroupSearch)
.then(config => {
if (!config) {
return;
}
this.$.restAPI.getIncludedGroup(this._groupName)
.then(includedGroup => {
this._includedGroups = includedGroup;
});
this._includedGroupSearch = '';
});
},
_handleDeleteIncludedGroup(e) {
const name = e.model.get('item.name');
if (!name) {
return '';
}
this._itemName = name;
this._itemType = 'includedGroup';
this.$.overlay.open();
},
_getAccountSuggestions(input) {
if (input.length === 0) { return Promise.resolve([]); }
return this.$.restAPI.getSuggestedAccounts(
input, SUGGESTIONS_LIMIT).then(accounts => {
const accountSuggestions = [];
let nameAndEmail;
if (!accounts) { return []; }
for (const key in accounts) {
if (!accounts.hasOwnProperty(key)) { continue; }
if (accounts[key].email !== undefined) {
nameAndEmail = accounts[key].name +
' <' + accounts[key].email + '>';
} else {
nameAndEmail = accounts[key].name;
}
accountSuggestions.push({
name: nameAndEmail,
});
}
return accountSuggestions;
});
},
_getGroupSuggestions(input) {
return this.$.restAPI.getSuggestedGroups(input)
.then(response => {
const groups = [];
for (const key in response) {
if (!response.hasOwnProperty(key)) { continue; }
groups.push({
name: key,
value: response[key],
});
}
return groups;
});
},
});
})();

View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<!--
Copyright (C) 2017 The Android Open Source Project
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.
-->
<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
<title>gr-group-members</title>
<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
<script src="../../../bower_components/web-component-tester/browser.js"></script>
<link rel="import" href="../../../test/common-test-setup.html"/>
<link rel="import" href="gr-group-members.html">
<script>void(0);</script>
<test-fixture id="basic">
<template>
<gr-group-members></gr-group-members>
</template>
</test-fixture>
<script>
suite('gr-group-members tests', () => {
let element;
let sandbox;
let groups;
let groupMembers;
setup(() => {
sandbox = sinon.sandbox.create();
groups = {
name: 'Administrators',
owner: 'Administrators',
group_id: 1,
};
groupMembers = [
{
_account_id: 1000097,
name: 'Jane Roe',
email: 'jane.roe@example.com',
username: 'jane',
},
{
_account_id: 1000096,
name: 'Test User',
email: 'john.doe@example.com',
username: 'john',
},
{
_account_id: 1000095,
name: 'Gerrit',
email: 'gerrit@example.com',
username: 'git',
},
];
stub('gr-rest-api-interface', {
getSuggestedAccounts(input) {
if (input.startsWith('test')) {
return Promise.resolve([
{
_account_id: 1000096,
name: 'test-account',
email: 'test.account@example.com',
username: 'test123',
},
{
_account_id: 1001439,
name: 'test-admin',
email: 'test.admin@example.com',
username: 'test_admin',
},
{
_account_id: 1001439,
name: 'test-git',
username: 'test_git',
},
]);
} else {
return Promise.resolve({});
}
},
getLoggedIn() { return Promise.resolve(true); },
getGroupConfig() {
return Promise.resolve(groups);
},
getGroupMembers() {
return Promise.resolve(groupMembers);
},
getIsGroupOwner() {
return Promise.resolve(true);
},
});
element = fixture('basic');
});
teardown(() => {
sandbox.restore();
});
test('save correctly', () => {
element._groupOwner = true;
const memberName = 'test-admin';
sandbox.stub(element.$.restAPI, 'saveGroupMembers', () => {
return Promise.resolve({});
});
const button = Polymer.dom(element.root).querySelector('gr-button');
assert.isTrue(button.hasAttribute('disabled'));
element.$.groupMemberSearchInput.text = memberName;
assert.isFalse(button.hasAttribute('disabled'));
element._handleSavingGroupMember().then(() => {
assert.isTrue(button.hasAttribute('disabled'));
assert.isFalse(element.$.Title.classList.contains('edited'));
});
});
test('_getAccountSuggestions empty', done => {
element._getAccountSuggestions('nonexistent').then(accounts => {
assert.equal(accounts.length, 0);
done();
});
});
test('_getAccountSuggestions non-empty', done => {
element._getAccountSuggestions('test-').then(accounts => {
assert.equal(accounts.length, 3);
assert.equal(accounts[0].name,
'test-account <test.account@example.com>');
assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
assert.equal(accounts[2].name, 'test-git');
done();
});
});
});
</script>

View File

@@ -71,12 +71,12 @@ limitations under the License.
<gr-autocomplete
id="groupNameInput"
text="{{_groupConfig.name}}"
disabled$="[[_groupOwner]]"></gr-autocomplete>
disabled="[[!_groupOwner]]"></gr-autocomplete>
</span>
<gr-button
id="inputUpdateNameBtn"
on-tap="_handleSaveName"
disabled$="[[_computeButtonDisabled(_groupOwner, _rename)]]">
disabled="[[_computeButtonDisabled(_groupOwner, _rename)]]">
Rename Group</gr-button>
</fieldset>
<h3 class$="[[_computeHeaderClass(_owner)]]">
@@ -87,12 +87,12 @@ limitations under the License.
<gr-autocomplete
text="{{_groupConfig.owner}}"
query="[[_query]]"
disabled$="[[_groupOwner]]">
disabled$="[[!_groupOwner]]">
</gr-autocomplete>
</span>
<gr-button
on-tap="_handleSaveOwner"
disabled$="[[_computeButtonDisabled(_groupOwner, _owner)]]">
disabled="[[_computeButtonDisabled(_groupOwner, _owner)]]">
Change Owners</gr-button>
</fieldset>
<h3 class$="[[_computeHeaderClass(_description)]]">
@@ -104,11 +104,11 @@ limitations under the License.
class="description"
autocomplete="on"
bind-value="{{_groupConfig.description}}"
disabled$="[[_groupOwner]]"></iron-autogrow-textarea>
disabled="[[!_groupOwner]]"></iron-autogrow-textarea>
</div>
<gr-button
on-tap="_handleSaveDescription"
disabled$=
disabled=
"[[_computeButtonDisabled(_groupOwner, _description)]]">
Save Description
</gr-button>
@@ -124,7 +124,7 @@ limitations under the License.
<span class="value">
<gr-select
bind-value="{{_groupConfig.options.visible_to_all}}">
<select disabled$="[[_groupOwner]]">
<select disabled$="[[!_groupOwner]]">
<template is="dom-repeat" items="[[_submitTypes]]">
<option value="[[item.value]]">[[item.label]]</option>
</template>
@@ -134,7 +134,7 @@ limitations under the License.
</section>
<gr-button
on-tap="_handleSaveOptions"
disabled$=
disabled=
"[[_computeButtonDisabled(_groupOwner, _options)]]">
Save Group Options
</gr-button>

View File

@@ -100,14 +100,10 @@
config => {
this._groupConfig = config;
this._groupName = config.name;
this.fire('title-change', {title: config.name});
this._loading = false;
this.$.restAPI.getIsGroupOwner(config.name).then(
configs => {
if (Object.keys(configs).length === 0 &&
configs.constructor === Object) {
this._groupOwner = true;
}
});
this.$.restAPI.getIsGroupOwner(config.name)
.then(isOwner => { this._groupOwner = isOwner; });
});
},
@@ -187,7 +183,7 @@
},
_computeButtonDisabled(options, option) {
return options || !option;
return !options || !option;
},
_computeHeaderClass(configChanged) {

View File

@@ -76,9 +76,10 @@ limitations under the License.
name: groupName,
};
element._groupName = groupName;
element._groupOwner = true;
sandbox.stub(element.$.restAPI, 'getIsGroupOwner', () => {
return Promise.resolve({is_owner: true});
return Promise.resolve(true);
});
sandbox.stub(element.$.restAPI, 'saveGroupName', () => {

View File

@@ -152,7 +152,7 @@
});
});
// Matches /admin/groups/<group>,audit-log[/]
// Matches /admin/groups/<group>,audit-log
page(/^\/admin\/groups\/(.+),audit-log$/, loadUser, data => {
restAPI.getLoggedIn().then(loggedIn => {
if (loggedIn) {
@@ -168,6 +168,16 @@
});
});
// Matches /admin/groups/<group>,members
page(/^\/admin\/groups\/(.+),members$/, loadUser, data => {
app.params = {
view: Gerrit.Nav.View.ADMIN,
adminView: 'gr-group-members',
detailType: 'members',
groupId: data.params[0],
};
});
// Matches /admin/groups[,<offset>][/].
page(/^\/admin\/groups(,(\d+))?(\/)?$/, loadUser, data => {
restAPI.getLoggedIn().then(loggedIn => {

View File

@@ -311,9 +311,26 @@
revision, opt_errFn, opt_ctx);
},
getIsGroupOwner(groupId) {
const encodeId = encodeURIComponent(groupId);
return this._fetchSharedCacheURL('/groups/?owned&q=' + encodeId);
/**
* @param {!string} groupName
* @returns {!Promise<boolean>}
*/
getIsGroupOwner(groupName) {
const encodeName = encodeURIComponent(groupName);
return this._fetchSharedCacheURL('/groups/?owned&q=' + encodeName)
.then(configs => configs.hasOwnProperty(encodeName));
},
getGroupMembers(groupName) {
const encodeName = encodeURIComponent(groupName);
return this.send('GET', `/groups/${encodeName}/members/`)
.then(response => this.getResponseObject(response));
},
getIncludedGroup(groupName) {
const encodeName = encodeURIComponent(groupName);
return this.send('GET', `/groups/${encodeName}/groups/`)
.then(response => this.getResponseObject(response));
},
saveGroupName(groupId, name) {
@@ -341,6 +358,35 @@
return this._fetchSharedCacheURL('/groups/' + group + '/log.audit');
},
saveGroupMembers(groupName, groupMembers) {
const encodeName = encodeURIComponent(groupName);
const encodeMember = encodeURIComponent(groupMembers);
return this.send('PUT', `/groups/${encodeName}/members/${encodeMember}`)
.then(response => this.getResponseObject(response));
},
saveIncludedGroup(groupName, includedGroup) {
const encodeName = encodeURIComponent(groupName);
const encodeIncludedGroup = encodeURIComponent(includedGroup);
return this.send('PUT',
`/groups/${encodeName}/groups/${encodeIncludedGroup}`)
.then(response => this.getResponseObject(response));
},
deleteGroupMembers(groupName, groupMembers) {
const encodeName = encodeURIComponent(groupName);
const encodeMember = encodeURIComponent(groupMembers);
return this.send('DELETE',
`/groups/${encodeName}/members/${encodeMember}`);
},
deleteIncludedGroup(groupName, includedGroup) {
const encodeName = encodeURIComponent(groupName);
const encodeIncludedGroup = encodeURIComponent(includedGroup);
return this.send('DELETE',
`/groups/${encodeName}/groups/${encodeIncludedGroup}`);
},
getVersion() {
return this._fetchSharedCacheURL('/config/server/version');
},

View File

@@ -39,6 +39,7 @@ limitations under the License.
'admin/gr-create-project-dialog/gr-create-project-dialog_test.html',
'admin/gr-group/gr-group_test.html',
'admin/gr-group-audit-log/gr-group-audit-log_test.html',
'admin/gr-group-members/gr-group-members_test.html',
'admin/gr-plugin-list/gr-plugin-list_test.html',
'admin/gr-project/gr-project_test.html',
'admin/gr-project-detail-list/gr-project-detail-list_test.html',