blob: 933fc96a4fd7bdc42aa67881e824855dc48eab8d [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@polymer/paper-toggle-button/paper-toggle-button';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
import '../gr-rule-editor/gr-rule-editor';
import {css, html, LitElement, PropertyValues} from 'lit';
import {
toSortedPermissionsArray,
PermissionArrayItem,
PermissionArray,
AccessPermissionId,
} from '../../../utils/access-util';
import {customElement, property, query, state} from 'lit/decorators.js';
import {
LabelNameToLabelTypeInfoMap,
LabelTypeInfoValues,
GroupInfo,
GitRef,
RepoName,
} from '../../../types/common';
import {
AutocompleteQuery,
GrAutocomplete,
AutocompleteSuggestion,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {
EditablePermissionInfo,
EditablePermissionRuleInfo,
EditableRepoAccessGroups,
} from '../gr-repo-access/gr-repo-access-interfaces';
import {getAppContext} from '../../../services/app-context';
import {fire} from '../../../utils/event-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {paperStyles} from '../../../styles/gr-paper-styles';
import {grFormStyles} from '../../../styles/gr-form-styles';
import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
import {when} from 'lit/directives/when.js';
import {
AutocompleteCommitEvent,
ValueChangedEvent,
} from '../../../types/events';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
const MAX_AUTOCOMPLETE_RESULTS = 20;
const RANGE_NAMES = ['QUERY LIMIT', 'BATCH CHANGES LIMIT'];
type GroupsWithRulesMap = {[ruleId: string]: boolean};
interface ComputedLabelValue {
value: number;
text: string;
}
interface ComputedLabel {
name: string;
values: ComputedLabelValue[];
}
interface GroupSuggestion {
name: string;
value: GroupInfo;
}
@customElement('gr-permission')
export class GrPermission extends LitElement {
@property({type: String})
repo?: RepoName;
@property({type: Object})
labels?: LabelNameToLabelTypeInfoMap;
@property({type: String})
name?: string;
@property({type: Object})
permission?: PermissionArrayItem<EditablePermissionInfo>;
@property({type: Object})
groups?: EditableRepoAccessGroups;
@property({type: String})
section?: GitRef;
@property({type: Boolean})
editing = false;
@state()
private label?: ComputedLabel;
@state()
private groupFilter?: string;
@state()
private query: AutocompleteQuery;
@state()
rules?: PermissionArray<EditablePermissionRuleInfo | undefined>;
@state()
groupsWithRules?: GroupsWithRulesMap;
@state()
deleted = false;
@state()
originalExclusiveValue?: boolean;
@query('#groupAutocomplete')
private groupAutocomplete!: GrAutocomplete;
private readonly restApiService = getAppContext().restApiService;
constructor() {
super();
this.query = () => this.getGroupSuggestions();
this.addEventListener('access-saved', () => this.handleAccessSaved());
}
override connectedCallback() {
super.connectedCallback();
this.setupValues();
}
override willUpdate(changedProperties: PropertyValues<GrPermission>): void {
const oldEditing = changedProperties.get('editing');
if (oldEditing !== null && oldEditing !== undefined) {
this.handleEditingChanged(oldEditing);
}
if (
changedProperties.has('permission') ||
changedProperties.has('labels')
) {
this.label = this.computeLabel();
}
if (changedProperties.has('permission')) {
this.sortPermission(this.permission);
}
}
static override get styles() {
return [
sharedStyles,
paperStyles,
grFormStyles,
menuPageStyles,
css`
:host {
display: block;
margin-bottom: var(--spacing-m);
}
.header {
align-items: baseline;
display: flex;
justify-content: space-between;
margin: var(--spacing-s) var(--spacing-m);
}
.rules {
background: var(--table-header-background-color);
border: 1px solid var(--border-color);
border-bottom: 0;
}
.editing .rules {
border-bottom: 1px solid var(--border-color);
}
.title {
margin-bottom: var(--spacing-s);
}
#addRule,
#removeBtn {
display: none;
}
.right {
display: flex;
align-items: center;
}
.editing #removeBtn {
display: block;
margin-left: var(--spacing-xl);
}
.editing #addRule {
display: block;
padding: var(--spacing-m);
}
#deletedContainer,
.deleted #mainContainer {
display: none;
}
.deleted #deletedContainer {
align-items: baseline;
border: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
padding: var(--spacing-m);
}
#mainContainer {
display: block;
}
`,
];
}
override render() {
if (!this.section || !this.permission) {
return;
}
return html`
<section
id="permission"
class="gr-form-styles ${this.computeSectionClass(
this.editing,
this.deleted
)}"
>
<div id="mainContainer">
<div class="header">
<span class="title">${this.name}</span>
<div class="right">
${when(
!this.permissionIsOwnerOrGlobal(
this.permission.id ?? '',
this.section
),
() => html`
<paper-toggle-button
id="exclusiveToggle"
?checked=${this.permission?.value.exclusive}
?disabled=${!this.editing}
@change=${this.handleValueChange}
@click=${this.onTapExclusiveToggle}
></paper-toggle-button
>${this.computeExclusiveLabel(this.permission?.value)}
`
)}
<gr-button
link=""
id="removeBtn"
@click=${this.handleRemovePermission}
>Remove</gr-button
>
</div>
</div>
<!-- end header -->
<div class="rules">
${this.rules?.map(
(rule, index) => html`
<gr-rule-editor
.hasRange=${this.computeHasRange(this.name)}
.label=${this.label}
.editing=${this.editing}
.groupId=${rule.id}
.groupName=${this.computeGroupName(this.groups, rule.id)}
.permission=${this.permission!.id as AccessPermissionId}
.rule=${rule}
.section=${this.section}
@rule-changed=${(e: CustomEvent) =>
this.handleRuleChanged(e, index)}
@added-rule-removed=${(_: Event) =>
this.handleAddedRuleRemoved(index)}
></gr-rule-editor>
`
)}
<div id="addRule">
<gr-autocomplete
id="groupAutocomplete"
.text=${this.groupFilter ?? ''}
@text-changed=${(e: ValueChangedEvent) =>
(this.groupFilter = e.detail.value)}
.query=${this.query}
placeholder="Add group"
@commit=${this.handleAddRuleItem}
>
</gr-autocomplete>
</div>
<!-- end addRule -->
</div>
<!-- end rules -->
</div>
<!-- end mainContainer -->
<div id="deletedContainer">
<span>${this.name} was deleted</span>
<gr-button link="" id="undoRemoveBtn" @click=${this.handleUndoRemove}
>Undo</gr-button
>
</div>
<!-- end deletedContainer -->
</section>
`;
}
setupValues() {
if (!this.permission) {
return;
}
this.originalExclusiveValue = !!this.permission.value.exclusive;
this.requestUpdate();
}
private handleAccessSaved() {
// Set a new 'original' value to keep track of after the value has been
// saved.
this.setupValues();
}
private permissionIsOwnerOrGlobal(permissionId: string, section: string) {
return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
}
private handleEditingChanged(editingOld: boolean) {
// Ignore when editing gets set initially.
if (!editingOld) {
return;
}
if (!this.permission || !this.rules) {
return;
}
// Restore original values if no longer editing.
if (!this.editing) {
this.deleted = false;
delete this.permission.value.deleted;
this.groupFilter = '';
this.rules = this.rules.filter(rule => !rule.value!.added);
this.handleRulesChanged();
for (const key of Object.keys(this.permission.value.rules)) {
if (this.permission.value.rules[key].added) {
delete this.permission.value.rules[key];
}
}
// Restore exclusive bit to original.
this.permission.value.exclusive = this.originalExclusiveValue;
fire(this, 'permission-changed', {value: this.permission});
this.requestUpdate();
}
}
private handleAddedRuleRemoved(index: number) {
if (!this.rules) {
return;
}
this.rules = this.rules
.slice(0, index)
.concat(this.rules.slice(index + 1, this.rules.length));
this.handleRulesChanged();
}
handleValueChange(e: Event) {
if (!this.permission) {
return;
}
// Update entire permission object to trigger a re-render since permission
// is marked as @property
this.permission = {
...this.permission,
value: {
...this.permission.value,
modified: true,
exclusive: (e.target as HTMLInputElement).checked,
},
};
// Allows overall access page to know a change has been made.
fire(this, 'access-modified', {});
}
handleRemovePermission() {
if (!this.permission) {
return;
}
if (this.permission.value.added) {
fire(this, 'added-permission-removed', {});
}
this.deleted = true;
this.permission.value.deleted = true;
fire(this, 'access-modified', {});
}
private handleRulesChanged() {
if (!this.rules) {
return;
}
// Update the groups to exclude in the autocomplete.
this.groupsWithRules = this.computeGroupsWithRules(this.rules);
}
sortPermission(permission?: PermissionArrayItem<EditablePermissionInfo>) {
this.rules = toSortedPermissionsArray(permission?.value.rules);
this.handleRulesChanged();
}
computeSectionClass(editing: boolean, deleted: boolean) {
const classList = [];
if (editing) {
classList.push('editing');
}
if (deleted) {
classList.push('deleted');
}
return classList.join(' ');
}
handleUndoRemove() {
if (!this.permission) {
return;
}
this.deleted = false;
delete this.permission.value.deleted;
}
computeLabel(): ComputedLabel | undefined {
const {permission, labels} = this;
if (
!labels ||
!permission ||
!permission.value ||
!permission.value.label
) {
return;
}
const labelName = permission.value.label;
// It is possible to have a label name that is not included in the
// 'labels' object. In this case, treat it like anything else.
if (!labels[labelName]) {
return;
}
return {
name: labelName,
values: this.computeLabelValues(labels[labelName].values),
};
}
computeLabelValues(values: LabelTypeInfoValues): ComputedLabelValue[] {
const valuesArr: ComputedLabelValue[] = [];
const keys = Object.keys(values).sort((a, b) => Number(a) - Number(b));
for (const key of keys) {
let text = values[key];
if (!text) {
text = '';
}
// The value from the server being used to choose which item is
// selected is in integer form, so this must be converted.
valuesArr.push({value: Number(key), text});
}
return valuesArr;
}
computeGroupsWithRules(
rules: PermissionArray<EditablePermissionRuleInfo | undefined>
): GroupsWithRulesMap {
const groups: GroupsWithRulesMap = {};
for (const rule of rules) {
groups[rule.id] = true;
}
return groups;
}
computeGroupName(
groups: EditableRepoAccessGroups | undefined,
groupId: GitRef
) {
return groups && groups[groupId] && groups[groupId].name
? groups[groupId].name
: groupId;
}
getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
return this.restApiService
.getSuggestedGroups(
this.groupFilter || '',
this.repo,
MAX_AUTOCOMPLETE_RESULTS,
throwingErrorCallback
)
.then(response => {
const groups: GroupSuggestion[] = [];
for (const [name, value] of Object.entries(response ?? {})) {
groups.push({name, value});
}
// Does not return groups in which we already have rules for.
return groups
.filter(
group =>
this.groupsWithRules && !this.groupsWithRules[group.value.id]
)
.map((group: GroupSuggestion) => {
const autocompleteSuggestion: AutocompleteSuggestion = {
name: group.name,
value: group.value.id,
};
return autocompleteSuggestion;
});
});
}
/**
* Handles adding a skeleton item to the dom-repeat.
* gr-rule-editor handles setting the default values.
*/
async handleAddRuleItem(e: AutocompleteCommitEvent) {
if (!this.permission || !this.rules) {
return;
}
// The group id is encoded, but have to decode in order for the access
// API to work as expected.
const groupId = decodeURIComponent(e.detail.value).replace(/\+/g, ' ');
// We cannot use "this.set(...)" here, because groupId may contain dots,
// and dots in property path names are totally unsupported by Polymer.
// Apparently Polymer picks up this change anyway, otherwise we should
// have looked at using MutableData:
// https://polymer-library.polymer-project.org/2.0/docs/devguide/data-system#mutable-data
// Actual value assigned below, after the flush
this.permission.value.rules[groupId] = {} as EditablePermissionRuleInfo;
// Purposely don't recompute sorted array so that the newly added rule
// is the last item of the array.
this.rules.push({
id: groupId as GitRef,
value: undefined,
});
// Wait for new rule to get value populated via gr-rule-editor, and then
// add to permission values as well, so that the change gets propagated
// back to the section. Since the rule is inside a dom-repeat, a flush
// is needed.
this.requestUpdate();
await this.updateComplete;
// Add the new group name to the groups object so the name renders
// correctly.
if (this.groups && !this.groups[groupId]) {
this.groups[groupId] = {name: this.groupAutocomplete.text};
}
// Clear the text of the auto-complete box, so that the user can add the
// next group.
this.groupAutocomplete.text = '';
const value = this.rules[this.rules.length - 1].value;
value!.added = true;
this.permission.value.rules[groupId] = value!;
fire(this, 'access-modified', {});
this.requestUpdate();
}
computeHasRange(name?: string) {
if (!name) {
return false;
}
return RANGE_NAMES.includes(name.toUpperCase());
}
private computeExclusiveLabel(permission?: EditablePermissionInfo) {
return permission?.exclusive ? 'Exclusive' : 'Not Exclusive';
}
/**
* Work around a issue on iOS when clicking turns into double tap
*/
private onTapExclusiveToggle(e: Event) {
e.preventDefault();
}
// TODO: Do not use generic `CustomEvent`.
// There is something fishy going on here though.
// `e.detail.value` is of type `Rule`, but `splice()` expects a `number`.
// Did not look closer, but this seems to be broken. Should `e.detail.value`
// be replaced by `1` maybe??
private handleRuleChanged(e: CustomEvent, index: number) {
this.rules!.splice(index, e.detail.value);
this.handleRulesChanged();
this.requestUpdate();
}
}
declare global {
interface HTMLElementEventMap {
/** Fired when a permission that was previously added was removed. */
'added-permission-removed': CustomEvent<{}>;
'permission-changed': ValueChangedEvent<
PermissionArrayItem<EditablePermissionInfo>
>;
}
interface HTMLElementTagNameMap {
'gr-permission': GrPermission;
}
}