
After change 290410, we have converted gr-rest-api into a service and no longer require this import. Change-Id: I7990253fc5c25bab8d9d28ee594754a2eb78947d
608 lines
17 KiB
TypeScript
608 lines
17 KiB
TypeScript
/**
|
|
* @license
|
|
* 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.
|
|
*/
|
|
import '../../../styles/gr-menu-page-styles';
|
|
import '../../../styles/gr-subpage-styles';
|
|
import '../../../styles/shared-styles';
|
|
import '../gr-access-section/gr-access-section';
|
|
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
|
|
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
|
|
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
|
|
import {PolymerElement} from '@polymer/polymer/polymer-element';
|
|
import {htmlTemplate} from './gr-repo-access_html';
|
|
import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
|
|
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
|
|
import {toSortedPermissionsArray} from '../../../utils/access-util';
|
|
import {customElement, property} from '@polymer/decorators';
|
|
import {
|
|
RepoName,
|
|
ProjectInfo,
|
|
CapabilityInfoMap,
|
|
LabelNameToLabelTypeInfoMap,
|
|
ProjectAccessInput,
|
|
GitRef,
|
|
UrlEncodedRepoName,
|
|
ProjectAccessGroups,
|
|
} from '../../../types/common';
|
|
import {hasOwnProperty} from '../../../utils/common-util';
|
|
import {GrButton} from '../../shared/gr-button/gr-button';
|
|
import {GrAccessSection} from '../gr-access-section/gr-access-section';
|
|
import {
|
|
AutocompleteQuery,
|
|
AutocompleteSuggestion,
|
|
} from '../../shared/gr-autocomplete/gr-autocomplete';
|
|
import {
|
|
EditableLocalAccessSectionInfo,
|
|
PermissionAccessSection,
|
|
PropertyTreeNode,
|
|
PrimitiveValue,
|
|
} from './gr-repo-access-interfaces';
|
|
import {firePageError, fireAlert} from '../../../utils/event-util';
|
|
import {appContext} from '../../../services/app-context';
|
|
|
|
const NOTHING_TO_SAVE = 'No changes to save.';
|
|
|
|
const MAX_AUTOCOMPLETE_RESULTS = 50;
|
|
|
|
/**
|
|
* Fired when save is a no-op
|
|
*
|
|
* @event show-alert
|
|
*/
|
|
@customElement('gr-repo-access')
|
|
export class GrRepoAccess extends GestureEventListeners(
|
|
LegacyElementMixin(PolymerElement)
|
|
) {
|
|
static get template() {
|
|
return htmlTemplate;
|
|
}
|
|
|
|
@property({type: String, observer: '_repoChanged'})
|
|
repo?: RepoName;
|
|
|
|
@property({type: String})
|
|
path?: string;
|
|
|
|
@property({type: Boolean})
|
|
_canUpload?: boolean = false; // restAPI can return undefined
|
|
|
|
@property({type: String})
|
|
_inheritFromFilter?: RepoName;
|
|
|
|
@property({type: Object})
|
|
_query: AutocompleteQuery;
|
|
|
|
@property({type: Array})
|
|
_ownerOf?: GitRef[];
|
|
|
|
@property({type: Object})
|
|
_capabilities?: CapabilityInfoMap;
|
|
|
|
@property({type: Object})
|
|
_groups?: ProjectAccessGroups;
|
|
|
|
@property({type: Object})
|
|
_inheritsFrom?: ProjectInfo | null | {};
|
|
|
|
@property({type: Object})
|
|
_labels?: LabelNameToLabelTypeInfoMap;
|
|
|
|
@property({type: Object})
|
|
_local?: EditableLocalAccessSectionInfo;
|
|
|
|
@property({type: Boolean, observer: '_handleEditingChanged'})
|
|
_editing = false;
|
|
|
|
@property({type: Boolean})
|
|
_modified = false;
|
|
|
|
@property({type: Array})
|
|
_sections?: PermissionAccessSection[];
|
|
|
|
@property({type: Array})
|
|
_weblinks?: string[];
|
|
|
|
@property({type: Boolean})
|
|
_loading = true;
|
|
|
|
private _originalInheritsFrom?: ProjectInfo | null;
|
|
|
|
private restApiService = appContext.restApiService;
|
|
|
|
constructor() {
|
|
super();
|
|
this._query = () => this._getInheritFromSuggestions();
|
|
}
|
|
|
|
/** @override */
|
|
created() {
|
|
super.created();
|
|
this.addEventListener('access-modified', () =>
|
|
this._handleAccessModified()
|
|
);
|
|
}
|
|
|
|
_handleAccessModified() {
|
|
this._modified = true;
|
|
}
|
|
|
|
_repoChanged(repo: RepoName) {
|
|
this._loading = true;
|
|
|
|
if (!repo) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return this._reload(repo);
|
|
}
|
|
|
|
_reload(repo: RepoName) {
|
|
const errFn = (response?: Response | null) => {
|
|
firePageError(this, response);
|
|
};
|
|
|
|
this._editing = false;
|
|
|
|
// Always reset sections when a project changes.
|
|
this._sections = [];
|
|
const sectionsPromises = this.restApiService
|
|
.getRepoAccessRights(repo, errFn)
|
|
.then(res => {
|
|
if (!res) {
|
|
return Promise.resolve(undefined);
|
|
}
|
|
|
|
// Keep a copy of the original inherit from values separate from
|
|
// the ones data bound to gr-autocomplete, so the original value
|
|
// can be restored if the user cancels.
|
|
this._inheritsFrom = res.inherits_from
|
|
? {
|
|
...res.inherits_from,
|
|
}
|
|
: null;
|
|
this._originalInheritsFrom = res.inherits_from
|
|
? {
|
|
...res.inherits_from,
|
|
}
|
|
: null;
|
|
// Initialize the filter value so when the user clicks edit, the
|
|
// current value appears. If there is no parent repo, it is
|
|
// initialized as an empty string.
|
|
this._inheritFromFilter = res.inherits_from
|
|
? res.inherits_from.name
|
|
: ('' as RepoName);
|
|
// 'as EditableLocalAccessSectionInfo' is required because res.local
|
|
// type doesn't have index signature
|
|
this._local = res.local as EditableLocalAccessSectionInfo;
|
|
this._groups = res.groups;
|
|
this._weblinks = res.config_web_links || [];
|
|
this._canUpload = res.can_upload;
|
|
this._ownerOf = res.owner_of || [];
|
|
return toSortedPermissionsArray(this._local);
|
|
});
|
|
|
|
const capabilitiesPromises = this.restApiService
|
|
.getCapabilities(errFn)
|
|
.then(res => {
|
|
if (!res) {
|
|
return Promise.resolve(undefined);
|
|
}
|
|
|
|
return res;
|
|
});
|
|
|
|
const labelsPromises = this.restApiService
|
|
.getRepo(repo, errFn)
|
|
.then(res => {
|
|
if (!res) {
|
|
return Promise.resolve(undefined);
|
|
}
|
|
|
|
return res.labels;
|
|
});
|
|
|
|
return Promise.all([
|
|
sectionsPromises,
|
|
capabilitiesPromises,
|
|
labelsPromises,
|
|
]).then(([sections, capabilities, labels]) => {
|
|
this._capabilities = capabilities;
|
|
this._labels = labels;
|
|
this._sections = sections;
|
|
this._loading = false;
|
|
});
|
|
}
|
|
|
|
_handleUpdateInheritFrom(e: CustomEvent<{value: string}>) {
|
|
const parentProject: ProjectInfo = {
|
|
id: e.detail.value as UrlEncodedRepoName,
|
|
name: this._inheritFromFilter,
|
|
};
|
|
if (!this._inheritsFrom) {
|
|
this._inheritsFrom = parentProject;
|
|
} else {
|
|
// TODO(TS): replace with
|
|
// this._inheritsFrom = {...this._inheritsFrom, ...parentProject};
|
|
const projectInfo = this._inheritsFrom as ProjectInfo;
|
|
projectInfo.id = parentProject.id;
|
|
projectInfo.name = parentProject.name;
|
|
}
|
|
this._handleAccessModified();
|
|
}
|
|
|
|
_getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
|
|
return this.restApiService
|
|
.getRepos(this._inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
|
|
.then(response => {
|
|
const projects: AutocompleteSuggestion[] = [];
|
|
if (!response) {
|
|
return projects;
|
|
}
|
|
for (const item of response) {
|
|
projects.push({
|
|
name: item.name,
|
|
value: item.id,
|
|
});
|
|
}
|
|
return projects;
|
|
});
|
|
}
|
|
|
|
_computeLoadingClass(loading: boolean) {
|
|
return loading ? 'loading' : '';
|
|
}
|
|
|
|
_handleEdit() {
|
|
this._editing = !this._editing;
|
|
}
|
|
|
|
_editOrCancel(editing: boolean) {
|
|
return editing ? 'Cancel' : 'Edit';
|
|
}
|
|
|
|
_computeWebLinkClass(weblinks?: string[]) {
|
|
return weblinks && weblinks.length ? 'show' : '';
|
|
}
|
|
|
|
_computeShowInherit(inheritsFrom?: RepoName) {
|
|
return inheritsFrom ? 'show' : '';
|
|
}
|
|
|
|
// TODO(TS): Unclear what is model here, provide a better explanation
|
|
_handleAddedSectionRemoved(e: CustomEvent & {model: {index: string}}) {
|
|
if (!this._sections) {
|
|
return;
|
|
}
|
|
const index = Number(e.model.index);
|
|
if (isNaN(index)) {
|
|
return;
|
|
}
|
|
this._sections = this._sections
|
|
.slice(0, index)
|
|
.concat(this._sections.slice(index + 1, this._sections.length));
|
|
}
|
|
|
|
_handleEditingChanged(editing: boolean, editingOld: boolean) {
|
|
// Ignore when editing gets set initially.
|
|
if (!editingOld || editing) {
|
|
return;
|
|
}
|
|
// Remove any unsaved but added refs.
|
|
if (this._sections) {
|
|
this._sections = this._sections.filter(p => !p.value.added);
|
|
}
|
|
// Restore inheritFrom.
|
|
if (this._inheritsFrom) {
|
|
this._inheritsFrom = {...this._originalInheritsFrom};
|
|
this._inheritFromFilter =
|
|
'name' in this._inheritsFrom ? this._inheritsFrom.name : undefined;
|
|
}
|
|
if (!this._local) {
|
|
return;
|
|
}
|
|
for (const key of Object.keys(this._local)) {
|
|
if (this._local[key].added) {
|
|
delete this._local[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
_updateRemoveObj(addRemoveObj: {remove: PropertyTreeNode}, path: string[]) {
|
|
let curPos: PropertyTreeNode = addRemoveObj.remove;
|
|
for (const item of path) {
|
|
if (!curPos[item]) {
|
|
if (item === path[path.length - 1]) {
|
|
if (path[path.length - 2] === 'permissions') {
|
|
curPos[item] = {rules: {}};
|
|
} else if (path.length === 1) {
|
|
curPos[item] = {permissions: {}};
|
|
} else {
|
|
curPos[item] = {};
|
|
}
|
|
} else {
|
|
curPos[item] = {};
|
|
}
|
|
}
|
|
// The last item can be a PrimitiveValue, but we don't use it
|
|
// All intermediate items are PropertyTreeNode
|
|
// TODO(TS): rewrite this loop and process the last item explicitly
|
|
curPos = curPos[item] as PropertyTreeNode;
|
|
}
|
|
return addRemoveObj;
|
|
}
|
|
|
|
_updateAddObj(
|
|
addRemoveObj: {add: PropertyTreeNode},
|
|
path: string[],
|
|
value: PropertyTreeNode | PrimitiveValue
|
|
) {
|
|
let curPos: PropertyTreeNode = addRemoveObj.add;
|
|
for (const item of path) {
|
|
if (!curPos[item]) {
|
|
if (item === path[path.length - 1]) {
|
|
curPos[item] = value;
|
|
} else {
|
|
curPos[item] = {};
|
|
}
|
|
}
|
|
// The last item can be a PrimitiveValue, but we don't use it
|
|
// All intermediate items are PropertyTreeNode
|
|
// TODO(TS): rewrite this loop and process the last item explicitly
|
|
curPos = curPos[item] as PropertyTreeNode;
|
|
}
|
|
return addRemoveObj;
|
|
}
|
|
|
|
/**
|
|
* Used to recursively remove any objects with a 'deleted' bit.
|
|
*/
|
|
_recursivelyRemoveDeleted(obj: PropertyTreeNode) {
|
|
for (const k in obj) {
|
|
if (!hasOwnProperty(obj, k)) {
|
|
continue;
|
|
}
|
|
const node = obj[k];
|
|
if (typeof node === 'object') {
|
|
if (node.deleted) {
|
|
delete obj[k];
|
|
return;
|
|
}
|
|
this._recursivelyRemoveDeleted(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
_recursivelyUpdateAddRemoveObj(
|
|
obj: PropertyTreeNode,
|
|
addRemoveObj: {
|
|
add: PropertyTreeNode;
|
|
remove: PropertyTreeNode;
|
|
},
|
|
path: string[] = []
|
|
) {
|
|
for (const k in obj) {
|
|
if (!hasOwnProperty(obj, k)) {
|
|
continue;
|
|
}
|
|
const node = obj[k];
|
|
if (typeof node === 'object') {
|
|
const updatedId = node.updatedId;
|
|
const ref = updatedId ? updatedId : k;
|
|
if (node.deleted) {
|
|
this._updateRemoveObj(addRemoveObj, path.concat(k));
|
|
continue;
|
|
} else if (node.modified) {
|
|
this._updateRemoveObj(addRemoveObj, path.concat(k));
|
|
this._updateAddObj(addRemoveObj, path.concat(ref), node);
|
|
/* Special case for ref changes because they need to be added and
|
|
removed in a different way. The new ref needs to include all
|
|
changes but also the initial state. To do this, instead of
|
|
continuing with the same recursion, just remove anything that is
|
|
deleted in the current state. */
|
|
if (updatedId && updatedId !== k) {
|
|
this._recursivelyRemoveDeleted(
|
|
addRemoveObj.add[updatedId] as PropertyTreeNode
|
|
);
|
|
}
|
|
continue;
|
|
} else if (node.added) {
|
|
this._updateAddObj(addRemoveObj, path.concat(ref), node);
|
|
/**
|
|
* As add / delete both can happen in the new section,
|
|
* so here to make sure it will remove the deleted ones.
|
|
*
|
|
* @see Issue 11339
|
|
*/
|
|
this._recursivelyRemoveDeleted(
|
|
addRemoveObj.add[k] as PropertyTreeNode
|
|
);
|
|
continue;
|
|
}
|
|
this._recursivelyUpdateAddRemoveObj(node, addRemoveObj, path.concat(k));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns an object formatted for saving or submitting access changes for
|
|
* review
|
|
*/
|
|
_computeAddAndRemove() {
|
|
const addRemoveObj: {
|
|
add: PropertyTreeNode;
|
|
remove: PropertyTreeNode;
|
|
parent?: string | null;
|
|
} = {
|
|
add: {},
|
|
remove: {},
|
|
};
|
|
|
|
const originalInheritsFromId = this._originalInheritsFrom
|
|
? singleDecodeURL(this._originalInheritsFrom.id)
|
|
: null;
|
|
// TODO(TS): this._inheritsFrom as ProjectInfo might be a mistake.
|
|
// _inheritsFrom can be {}
|
|
const inheritsFromId = this._inheritsFrom
|
|
? singleDecodeURL((this._inheritsFrom as ProjectInfo).id)
|
|
: null;
|
|
|
|
const inheritFromChanged =
|
|
// Inherit from changed
|
|
(originalInheritsFromId && originalInheritsFromId !== inheritsFromId) ||
|
|
// Inherit from added (did not have one initially);
|
|
(!originalInheritsFromId && inheritsFromId);
|
|
|
|
if (!this._local) {
|
|
return addRemoveObj;
|
|
}
|
|
|
|
this._recursivelyUpdateAddRemoveObj(
|
|
(this._local as unknown) as PropertyTreeNode,
|
|
addRemoveObj
|
|
);
|
|
|
|
if (inheritFromChanged) {
|
|
addRemoveObj.parent = inheritsFromId;
|
|
}
|
|
return addRemoveObj;
|
|
}
|
|
|
|
_handleCreateSection() {
|
|
if (!this._local) {
|
|
return;
|
|
}
|
|
let newRef = 'refs/for/*';
|
|
// Avoid using an already used key for the placeholder, since it
|
|
// immediately gets added to an object.
|
|
while (this._local[newRef]) {
|
|
newRef = `${newRef}*`;
|
|
}
|
|
const section = {permissions: {}, added: true};
|
|
this.push('_sections', {id: newRef, value: section});
|
|
this.set(['_local', newRef], section);
|
|
flush();
|
|
// Template already instantiated at this point
|
|
(this.root!.querySelector(
|
|
'gr-access-section:last-of-type'
|
|
) as GrAccessSection).editReference();
|
|
}
|
|
|
|
_getObjforSave(): ProjectAccessInput | undefined {
|
|
const addRemoveObj = this._computeAddAndRemove();
|
|
// If there are no changes, don't actually save.
|
|
if (
|
|
!Object.keys(addRemoveObj.add).length &&
|
|
!Object.keys(addRemoveObj.remove).length &&
|
|
!addRemoveObj.parent
|
|
) {
|
|
fireAlert(this, NOTHING_TO_SAVE);
|
|
return;
|
|
}
|
|
const obj: ProjectAccessInput = ({
|
|
add: addRemoveObj.add,
|
|
remove: addRemoveObj.remove,
|
|
} as unknown) as ProjectAccessInput;
|
|
if (addRemoveObj.parent) {
|
|
obj.parent = addRemoveObj.parent;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
_handleSave(e: Event) {
|
|
const obj = this._getObjforSave();
|
|
if (!obj) {
|
|
return;
|
|
}
|
|
const button = e && (e.target as GrButton);
|
|
if (button) {
|
|
button.loading = true;
|
|
}
|
|
const repo = this.repo;
|
|
if (!repo) {
|
|
return Promise.resolve();
|
|
}
|
|
return this.restApiService
|
|
.setRepoAccessRights(repo, obj)
|
|
.then(() => {
|
|
this._reload(repo);
|
|
})
|
|
.finally(() => {
|
|
this._modified = false;
|
|
if (button) {
|
|
button.loading = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
_handleSaveForReview(e: Event) {
|
|
const obj = this._getObjforSave();
|
|
if (!obj) {
|
|
return;
|
|
}
|
|
const button = e && (e.target as GrButton);
|
|
if (button) {
|
|
button.loading = true;
|
|
}
|
|
if (!this.repo) {
|
|
return;
|
|
}
|
|
return this.restApiService
|
|
.setRepoAccessRightsForReview(this.repo, obj)
|
|
.then(change => {
|
|
GerritNav.navigateToChange(change);
|
|
})
|
|
.finally(() => {
|
|
this._modified = false;
|
|
if (button) {
|
|
button.loading = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
_computeSaveReviewBtnClass(canUpload?: boolean) {
|
|
return !canUpload ? 'invisible' : '';
|
|
}
|
|
|
|
_computeSaveBtnClass(ownerOf?: GitRef[]) {
|
|
return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
|
|
}
|
|
|
|
_computeMainClass(
|
|
ownerOf: GitRef[] | undefined,
|
|
canUpload: boolean,
|
|
editing: boolean
|
|
) {
|
|
const classList = [];
|
|
if ((ownerOf && ownerOf.length > 0) || canUpload) {
|
|
classList.push('admin');
|
|
}
|
|
if (editing) {
|
|
classList.push('editing');
|
|
}
|
|
return classList.join(' ');
|
|
}
|
|
|
|
_computeParentHref(repoName: RepoName) {
|
|
return getBaseUrl() + `/admin/repos/${encodeURL(repoName, true)},access`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'gr-repo-access': GrRepoAccess;
|
|
}
|
|
}
|