| /** |
| * @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; |
| } |
| } |