Created
September 20, 2025 12:07
-
-
Save paladox/251e867dc8fa274d04db7a4fb117f518 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @license | |
| * Copyright 2017 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import '../../shared/gr-button/gr-button'; | |
| import '../../shared/gr-icon/gr-icon'; | |
| import '../gr-permission/gr-permission'; | |
| import { | |
| AccessPermissions, | |
| PermissionArray, | |
| PermissionArrayItem, | |
| toSortedPermissionsArray, | |
| } from '../../../utils/access-util'; | |
| import { | |
| EditablePermissionInfo, | |
| EditableRepoAccessGroups, | |
| PermissionAccessSection, | |
| } from '../gr-repo-access/gr-repo-access-interfaces'; | |
| import { | |
| CapabilityInfoMap, | |
| GitRef, | |
| LabelNameToLabelTypeInfoMap, | |
| RepoName, | |
| } from '../../../types/common'; | |
| import {fire} from '../../../utils/event-util'; | |
| import {fontStyles} from '../../../styles/gr-font-styles'; | |
| import {grFormStyles} from '../../../styles/gr-form-styles'; | |
| import {sharedStyles} from '../../../styles/shared-styles'; | |
| import {css, html, LitElement, PropertyValues} from 'lit'; | |
| import {customElement, property, query, state} from 'lit/decorators.js'; | |
| import {ValueChangedEvent} from '../../../types/events'; | |
| import {assertIsDefined, queryAndAssert} from '../../../utils/common-util'; | |
| import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field'; | |
| import '@material/web/textfield/outlined-text-field'; | |
| import {materialStyles} from '../../../styles/gr-material-styles'; | |
| import '@material/web/select/outlined-select'; | |
| import '@material/web/select/select-option'; | |
| import {MdOutlinedSelect} from '@material/web/select/outlined-select'; | |
| import {repeat} from 'lit/directives/repeat.js'; | |
| const GLOBAL_NAME = 'GLOBAL_CAPABILITIES'; | |
| // The name that gets automatically input when a new reference is added. | |
| const NEW_NAME = 'refs/heads/*'; | |
| const REFS_NAME = 'refs/'; | |
| @customElement('gr-access-section') | |
| export class GrAccessSection extends LitElement { | |
| @query('#permissionSelect') private permissionSelect?: MdOutlinedSelect; | |
| @property({type: String}) | |
| repo?: RepoName; | |
| @property({type: Object}) | |
| capabilities?: CapabilityInfoMap; | |
| @property({type: Object}) | |
| section?: PermissionAccessSection; | |
| @property({type: Object}) | |
| groups?: EditableRepoAccessGroups; | |
| @property({type: Object}) | |
| labels?: LabelNameToLabelTypeInfoMap; | |
| @property({type: Boolean}) | |
| editing = false; | |
| @property({type: Boolean}) | |
| canUpload?: boolean; | |
| @property({type: Array}) | |
| ownerOf?: GitRef[]; | |
| // private but used in test | |
| @state() originalId?: GitRef; | |
| // private but used in test | |
| @state() editingRef = false; | |
| // private but used in test | |
| @state() deleted = false; | |
| // private but used in test | |
| @state() permissions?: PermissionArray<EditablePermissionInfo>; | |
| @state() private selectedIndex = 0; | |
| constructor() { | |
| super(); | |
| this.addEventListener('access-saved', () => this.handleAccessSaved()); | |
| } | |
| static override get styles() { | |
| return [ | |
| materialStyles, | |
| grFormStyles, | |
| fontStyles, | |
| sharedStyles, | |
| css` | |
| :host { | |
| display: block; | |
| margin-bottom: var(--spacing-l); | |
| } | |
| fieldset { | |
| border: 1px solid var(--border-color); | |
| } | |
| .name { | |
| align-items: center; | |
| display: flex; | |
| } | |
| .header, | |
| #deletedContainer { | |
| align-items: center; | |
| background: var(--table-header-background-color); | |
| border-bottom: 1px dotted var(--border-color); | |
| display: flex; | |
| justify-content: space-between; | |
| min-height: 3em; | |
| padding: 0 var(--spacing-m); | |
| } | |
| #deletedContainer { | |
| border-bottom: 0; | |
| } | |
| .sectionContent { | |
| padding: var(--spacing-m); | |
| } | |
| #editBtn, | |
| .editing #editBtn.global, | |
| #deletedContainer, | |
| .deleted #mainContainer, | |
| #addPermission, | |
| #deleteBtn, | |
| .editingRef .name, | |
| .editRefInput { | |
| display: none; | |
| } | |
| .editing #editBtn, | |
| .editingRef .editRefInput { | |
| display: flex; | |
| } | |
| .deleted #deletedContainer { | |
| display: flex; | |
| } | |
| .editing #addPermission, | |
| #mainContainer, | |
| .editing #deleteBtn { | |
| display: block; | |
| } | |
| .editing #deleteBtn, | |
| #undoRemoveBtn { | |
| padding-right: var(--spacing-m); | |
| } | |
| `, | |
| ]; | |
| } | |
| override render() { | |
| if (!this.section) return; | |
| const permissions = this.computePermissions(); | |
| return html` | |
| <fieldset | |
| id="section" | |
| class="gr-form-styles ${this.computeSectionClass()}" | |
| > | |
| <div id="mainContainer"> | |
| <div class="header"> | |
| <div class="name"> | |
| <h3 class="heading-3">${this.computeSectionName()}</h3> | |
| <gr-button | |
| id="editBtn" | |
| link | |
| class=${this.section?.id === GLOBAL_NAME ? 'global' : ''} | |
| @click=${this.editReference} | |
| > | |
| <gr-icon id="icon" icon="edit" filled small></gr-icon> | |
| </gr-button> | |
| </div> | |
| <md-outlined-text-field | |
| class="editRefInput showBlueFocusBorder" | |
| .value=${this.section?.id ?? ''} | |
| @input=${this.handleValueChange} | |
| > | |
| </md-outlined-text-field> | |
| <gr-button link id="deleteBtn" @click=${this.handleRemoveReference} | |
| >Remove</gr-button | |
| > | |
| </div> | |
| <!-- end header --> | |
| <div class="sectionContent"> | |
| ${this.permissions?.map((permission, index) => | |
| this.renderPermission(permission, index) | |
| )} | |
| <div id="addPermission"> | |
| Add permission: | |
| <md-outlined-select | |
| id="permissionSelect" | |
| value=${permissions[this.selectedIndex]?.value.id ?? | |
| permissions[0]?.value.id ?? | |
| ''} | |
| @change=${(e: Event) => { | |
| const sel = e.target as HTMLSelectElement; | |
| this.setSelectionIndex(sel.value); | |
| }} | |
| > | |
| ${repeat( | |
| permissions, | |
| item => item.value.id, | |
| item => html` | |
| <md-select-option value=${item.value.id}> | |
| <div slot="headline">${item.value.name}</div> | |
| </md-select-option> | |
| ` | |
| )} | |
| </md-outlined-select> | |
| <gr-button link id="addBtn" @click=${this.handleAddPermission} | |
| >Add</gr-button | |
| > | |
| </div> | |
| <!-- end addPermission --> | |
| </div> | |
| <!-- end sectionContent --> | |
| </div> | |
| <!-- end mainContainer --> | |
| <div id="deletedContainer"> | |
| <span>${this.computeSectionName()} was deleted</span> | |
| <gr-button link="" id="undoRemoveBtn" @click=${this.handleUndoRemove} | |
| >Undo</gr-button | |
| > | |
| </div> | |
| <!-- end deletedContainer --> | |
| </fieldset> | |
| `; | |
| } | |
| private renderPermission( | |
| permission: PermissionArrayItem<EditablePermissionInfo>, | |
| index: number | |
| ) { | |
| return html` | |
| <gr-permission | |
| .name=${this.computePermissionName(permission)} | |
| .permission=${permission} | |
| .labels=${this.labels} | |
| .section=${this.section?.id} | |
| .editing=${this.editing} | |
| .groups=${this.groups} | |
| .repo=${this.repo} | |
| @added-permission-removed=${() => { | |
| this.handleAddedPermissionRemoved(index); | |
| }} | |
| @permission-changed=${( | |
| e: ValueChangedEvent<PermissionArrayItem<EditablePermissionInfo>> | |
| ) => { | |
| this.handlePermissionChanged(e, index); | |
| }} | |
| > | |
| </gr-permission> | |
| `; | |
| } | |
| override willUpdate(changedProperties: PropertyValues) { | |
| if (changedProperties.has('section')) { | |
| this.updateSection(); | |
| } | |
| if (changedProperties.has('editing')) { | |
| this.handleEditingChanged(changedProperties.get('editing') as boolean); | |
| } | |
| } | |
| // private but used in test | |
| updateSection() { | |
| this.permissions = toSortedPermissionsArray( | |
| this.section!.value.permissions | |
| ); | |
| this.originalId = this.section!.id; | |
| } | |
| // private but used in test | |
| handleAccessSaved() { | |
| if (!this.section) return; | |
| // Set a new 'original' value to keep track of after the value has been | |
| // saved. | |
| this.updateSection(); | |
| } | |
| // private but used in test | |
| handleValueChange(e?: InputEvent) { | |
| if (e) { | |
| this.section!.id = (e.target as HTMLInputElement).value as GitRef; | |
| this.requestUpdate(); | |
| fire(this, 'section-changed', {value: this.section!}); | |
| } | |
| if (!this.section) { | |
| return; | |
| } | |
| if (!this.section.value.added) { | |
| this.section.value.modified = this.section.id !== this.originalId; | |
| this.requestUpdate(); | |
| // Allows overall access page to know a change has been made. | |
| // For a new section, this is not fired because new permissions and | |
| // rules have to be added in order to save, modifying the ref is not | |
| // enough. | |
| fire(this, 'access-modified', {}); | |
| } | |
| this.section.value.updatedId = this.section.id; | |
| this.requestUpdate(); | |
| } | |
| private handleEditingChanged(editingOld: boolean) { | |
| // Ignore when editing gets set initially. | |
| if (!editingOld) { | |
| return; | |
| } | |
| if (!this.section || !this.permissions) { | |
| return; | |
| } | |
| // Restore original values if no longer editing. | |
| if (!this.editing) { | |
| this.editingRef = false; | |
| this.deleted = false; | |
| delete this.section.value.deleted; | |
| // Restore section ref. | |
| this.section.id = this.originalId as GitRef; | |
| this.requestUpdate(); | |
| fire(this, 'section-changed', {value: this.section}); | |
| // Remove any unsaved but added permissions. | |
| this.permissions = this.permissions.filter(p => !p.value.added); | |
| for (const key of Object.keys(this.section.value.permissions)) { | |
| if (this.section.value.permissions[key].added) { | |
| delete this.section.value.permissions[key]; | |
| } | |
| } | |
| } | |
| } | |
| // private but used in test | |
| computePermissions() { | |
| let allPermissions; | |
| const section = this.section; | |
| if (!section || !section.value) { | |
| return []; | |
| } | |
| if (section.id === GLOBAL_NAME) { | |
| allPermissions = toSortedPermissionsArray(this.capabilities); | |
| } else { | |
| const labelOptions = this.computeLabelOptions(); | |
| allPermissions = labelOptions.concat( | |
| toSortedPermissionsArray(AccessPermissions) | |
| ); | |
| } | |
| return allPermissions.filter( | |
| permission => !section.value.permissions[permission.id] | |
| ); | |
| } | |
| private handleAddedPermissionRemoved(index: number) { | |
| if (!this.permissions) { | |
| return; | |
| } | |
| delete this.section?.value.permissions[this.permissions[index].id]; | |
| delete this.permissions[index]; | |
| if (this.permissions[0] === undefined) { | |
| this.permissions = []; | |
| } | |
| this.requestUpdate() | |
| } | |
| computeLabelOptions() { | |
| const labelOptions = []; | |
| if (!this.labels) { | |
| return []; | |
| } | |
| for (const labelName of Object.keys(this.labels)) { | |
| labelOptions.push({ | |
| id: 'label-' + labelName, | |
| value: { | |
| name: `Label ${labelName}`, | |
| id: 'label-' + labelName, | |
| }, | |
| }); | |
| labelOptions.push({ | |
| id: 'labelAs-' + labelName, | |
| value: { | |
| name: `Label ${labelName} (On Behalf Of)`, | |
| id: 'labelAs-' + labelName, | |
| }, | |
| }); | |
| labelOptions.push({ | |
| id: 'removeLabel-' + labelName, | |
| value: { | |
| name: `Remove Label ${labelName}`, | |
| id: 'removeLabel-' + labelName, | |
| }, | |
| }); | |
| } | |
| return labelOptions; | |
| } | |
| // private but used in test | |
| computePermissionName( | |
| permission: PermissionArrayItem<EditablePermissionInfo> | |
| ): string | undefined { | |
| if (this.section?.id === GLOBAL_NAME) { | |
| return this.capabilities?.[permission.id]?.name; | |
| } else if (AccessPermissions[permission.id]) { | |
| return AccessPermissions[permission.id]?.name; | |
| } else if (permission.value.label || permission.id) { | |
| if (permission.id.startsWith('labelAs-')) { | |
| return `Label ${ | |
| permission.value.label || permission.id.replace('labelAs-', '') | |
| } (On Behalf Of)`; | |
| } else if (permission.id.startsWith('removeLabel-')) { | |
| return `Remove Label ${ | |
| permission.value.label || permission.id.replace('removeLabel-', '') | |
| }`; | |
| } else { | |
| return `Label ${permission.value.label || permission.id}`; | |
| } | |
| } | |
| return undefined; | |
| } | |
| // private but used in test | |
| computeSectionName() { | |
| let name = this.section?.id; | |
| // When a new section is created, it doesn't yet have a ref. Set into | |
| // edit mode so that the user can input one. | |
| if (!name) { | |
| this.editingRef = true; | |
| // Needed for the title value. This is the same default as GWT. | |
| name = NEW_NAME as GitRef; | |
| // Needed for the input field value. | |
| this.section!.id = name; | |
| fire(this, 'section-changed', {value: this.section!}); | |
| this.requestUpdate(); | |
| } | |
| if (name === GLOBAL_NAME) { | |
| return 'Global Capabilities'; | |
| } else if (name.startsWith(REFS_NAME)) { | |
| return `Reference: ${name}`; | |
| } | |
| return name; | |
| } | |
| private handleRemoveReference() { | |
| if (!this.section) { | |
| return; | |
| } | |
| if (this.section.value.added) { | |
| fire(this, 'added-section-removed', {}); | |
| } | |
| this.deleted = true; | |
| this.section.value.deleted = true; | |
| fire(this, 'access-modified', {}); | |
| } | |
| private handleUndoRemove() { | |
| if (!this.section) { | |
| return; | |
| } | |
| this.deleted = false; | |
| delete this.section.value.deleted; | |
| this.requestUpdate(); | |
| } | |
| editRefInput() { | |
| return queryAndAssert<MdOutlinedTextField>( | |
| this, | |
| 'md-outlined-text-field.editRefInput' | |
| ); | |
| } | |
| editReference() { | |
| this.editingRef = true; | |
| this.editRefInput().focus(); | |
| } | |
| private isEditEnabled() { | |
| return ( | |
| this.canUpload || | |
| (this.ownerOf && this.ownerOf.indexOf(this.section!.id) >= 0) | |
| ); | |
| } | |
| // private but used in test | |
| computeSectionClass() { | |
| const classList = []; | |
| if (this.editing && this.section && this.isEditEnabled()) { | |
| classList.push('editing'); | |
| } | |
| if (this.editingRef) { | |
| classList.push('editingRef'); | |
| } | |
| if (this.deleted) { | |
| classList.push('deleted'); | |
| } | |
| return classList.join(' '); | |
| } | |
| // private but used in test | |
| handleAddPermission() { | |
| assertIsDefined(this.permissionSelect, 'permissionSelect'); | |
| const value = this.permissionSelect.getAttribute('value') as GitRef; | |
| this.setSelectionIndex(value); | |
| const permission: PermissionArrayItem<EditablePermissionInfo> = { | |
| id: value, | |
| value: {rules: {}, added: true}, | |
| }; | |
| // This is needed to update the 'label' property of the | |
| // 'label-<label-name>' permission. | |
| // | |
| // The value from the add permission dropdown will either be | |
| // label-<label-name> or labelAs-<labelName>. | |
| // But, the format of the API response is as such: | |
| // "permissions": { | |
| // "label-Code-Review": { | |
| // "label": "Code-Review", | |
| // "rules": {...} | |
| // } | |
| // } | |
| // } | |
| // When we add a new item, we have to push the new permission in the same | |
| // format as the ones that have been returned by the API. | |
| if (value.startsWith('label')) { | |
| permission.value.label = value | |
| .replace('label-', '') | |
| .replace('labelAs-', ''); | |
| } | |
| // Add to the end of the array (used in dom-repeat) and also to the | |
| // section object that is two way bound with its parent element. | |
| this.permissions!.push(permission); | |
| this.section!.value.permissions[permission.id] = permission.value; | |
| this.requestUpdate(); | |
| fire(this, 'section-changed', {value: this.section!}); | |
| } | |
| private handlePermissionChanged = ( | |
| e: ValueChangedEvent<PermissionArrayItem<EditablePermissionInfo>>, | |
| index: number | |
| ) => { | |
| this.permissions![index] = e.detail.value; | |
| this.requestUpdate(); | |
| }; | |
| private setSelectionIndex(value: string) { | |
| const currentIndex = this.computePermissions().findIndex( | |
| p => p.value.id === value | |
| ); | |
| const nextIndex = currentIndex % this.computePermissions().length; | |
| this.selectedIndex = nextIndex; | |
| } | |
| } | |
| declare global { | |
| interface HTMLElementEventMap { | |
| /** Fired when the section has been modified or removed. */ | |
| 'access-modified': CustomEvent<{}>; | |
| /** Fired when a section that was previously added was removed. */ | |
| 'added-section-removed': CustomEvent<{}>; | |
| 'section-changed': ValueChangedEvent<PermissionAccessSection>; | |
| } | |
| interface HTMLElementTagNameMap { | |
| 'gr-access-section': GrAccessSection; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment