blob: 975dd3b5f6394294904dfc6e52f9246b1fe6dcbe [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-select/gr-select';
import {encodeURL, getBaseUrl} from '../../../utils/url-util';
import {AccessPermissionId} from '../../../utils/access-util';
import {fireEvent} from '../../../utils/event-util';
import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
import {ifDefined} from 'lit/directives/if-defined.js';
import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
import {PermissionAction} from '../../../constants/constants';
/**
* Fired when the rule has been modified or removed.
*
* @event access-modified
*/
/**
* Fired when a rule that was previously added was removed.
*
* @event added-rule-removed
*/
const PRIORITY_OPTIONS = [PermissionAction.BATCH, PermissionAction.INTERACTIVE];
const Action = {
ALLOW: PermissionAction.ALLOW,
DENY: PermissionAction.DENY,
BLOCK: PermissionAction.BLOCK,
};
const DROPDOWN_OPTIONS = [
PermissionAction.ALLOW,
PermissionAction.DENY,
PermissionAction.BLOCK,
];
const ForcePushOptions = {
ALLOW: [
{name: 'Allow pushing (but not force pushing)', value: false},
{name: 'Allow pushing with or without force', value: true},
],
BLOCK: [
{name: 'Block pushing with or without force', value: false},
{name: 'Block force pushing', value: true},
],
};
const FORCE_EDIT_OPTIONS = [
{
name: 'No Force Edit',
value: false,
},
{
name: 'Force Edit',
value: true,
},
];
type Rule = {value?: EditablePermissionRuleInfo};
interface RuleLabel {
values: RuleLabelValue[];
}
interface RuleLabelValue {
value: number;
text: string;
}
declare global {
interface HTMLElementTagNameMap {
'gr-rule-editor': GrRuleEditor;
}
}
@customElement('gr-rule-editor')
export class GrRuleEditor extends LitElement {
@property({type: Boolean})
hasRange?: boolean;
@property({type: Object})
label?: RuleLabel;
@property({type: Boolean})
editing = false;
@property({type: String})
groupId?: string;
@property({type: String})
groupName?: string;
// This is required value for this component
@property({type: String})
permission!: AccessPermissionId;
@property({type: Object})
rule?: Rule;
@property({type: String})
section?: string;
// private but used in test
@state() deleted = false;
// private but used in test
@state() originalRuleValues?: EditablePermissionRuleInfo;
constructor() {
super();
this.addEventListener('access-saved', () => this.handleAccessSaved());
}
override connectedCallback() {
super.connectedCallback();
if (this.rule) {
this.setupValues();
}
// Check needed for test purposes.
if (!this.originalRuleValues && this.rule) {
this.setOriginalRuleValues();
}
}
static override get styles() {
return [
formStyles,
sharedStyles,
css`
:host {
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-m);
display: block;
}
#removeBtn {
display: none;
}
.editing #removeBtn {
display: flex;
}
#options {
align-items: baseline;
display: flex;
}
#options > * {
margin-right: var(--spacing-m);
}
#mainContainer {
align-items: baseline;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
}
#deletedContainer.deleted {
align-items: baseline;
display: flex;
justify-content: space-between;
}
#undoBtn,
#force,
#deletedContainer,
#mainContainer.deleted {
display: none;
}
#undoBtn.modified,
#force.force {
display: block;
}
.groupPath {
color: var(--deemphasized-text-color);
}
iron-autogrow-textarea {
width: 14em;
}
`,
];
}
override render() {
return html`
<div
id="mainContainer"
class="gr-form-styles ${this.computeSectionClass()}"
>
<div id="options">
<gr-select
id="action"
.bindValue=${this.rule?.value?.action}
@bind-value-changed=${(e: BindValueChangeEvent) => {
this.handleActionBindValueChanged(e);
}}
>
<select ?disabled=${!this.editing}>
${this.computeOptions().map(
item => html` <option value=${item}>${item}</option> `
)}
</select>
</gr-select>
${this.renderMinAndMaxLabel()} ${this.renderMinAndMaxInput()}
<a
class="groupPath"
href=${ifDefined(this.computeGroupPath(this.groupId))}
>
${this.groupName}
</a>
<gr-select
id="force"
class=${this.computeForce(this.rule?.value?.action) ? 'force' : ''}
.bindValue=${this.rule?.value?.force}
@bind-value-changed=${(e: BindValueChangeEvent) => {
this.handleForceBindValueChanged(e);
}}
>
<select ?disabled=${!this.editing}>
${this.computeForceOptions(this.rule?.value?.action).map(
item => html`
<option value=${item.value}>${item.name}</option>
`
)}
</select>
</gr-select>
</div>
<gr-button
link
id="removeBtn"
@click=${() => {
this.handleRemoveRule();
}}
>Remove</gr-button
>
</div>
<div
id="deletedContainer"
class="gr-form-styles ${this.computeSectionClass()}"
>
${this.groupName} was deleted
<gr-button
link
id="undoRemoveBtn"
@click=${() => {
this.handleUndoRemove();
}}
>Undo</gr-button
>
</div>
`;
}
private renderMinAndMaxLabel() {
if (!this.label) return;
return html`
<gr-select
id="labelMin"
.bindValue=${this.rule?.value?.min}
@bind-value-changed=${(e: BindValueChangeEvent) => {
this.handleMinBindValueChanged(e);
}}
>
<select ?disabled=${!this.editing}>
${this.label.values.map(
item => html` <option value=${item.value}>${item.value}</option> `
)}
</select>
</gr-select>
<gr-select
id="labelMax"
.bindValue=${this.rule?.value?.max}
@bind-value-changed=${(e: BindValueChangeEvent) => {
this.handleMaxBindValueChanged(e);
}}
>
<select ?disabled=${!this.editing}>
${this.label.values.map(
item => html` <option value=${item.value}>${item.value}</option> `
)}
</select>
</gr-select>
`;
}
private renderMinAndMaxInput() {
if (!this.hasRange) return;
return html`
<iron-autogrow-textarea
id="minInput"
class="min"
autocomplete="on"
placeholder="Min value"
.bindValue=${this.rule?.value?.min}
?disabled=${!this.editing}
@bind-value-changed=${(e: BindValueChangeEvent) => {
this.handleMinBindValueChanged(e);
}}
></iron-autogrow-textarea>
<iron-autogrow-textarea
id="maxInput"
class="max"
autocomplete="on"
placeholder="Max value"
.bindValue=${this.rule?.value?.max}
?disabled=${!this.editing}
@bind-value-changed=${(e: BindValueChangeEvent) => {
this.handleMaxBindValueChanged(e);
}}
></iron-autogrow-textarea>
`;
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('editing')) {
this.handleEditingChanged(changedProperties.get('editing') as boolean);
}
}
// private but used in test
setupValues() {
if (!this.rule?.value) {
this.setDefaultRuleValues();
}
}
// private but used in test
computeForce(action?: string) {
if (AccessPermissionId.PUSH === this.permission && action !== Action.DENY) {
return true;
}
return AccessPermissionId.EDIT_TOPIC_NAME === this.permission;
}
// private but used in test
computeGroupPath(groupId?: string) {
if (!groupId) return;
return `${getBaseUrl()}/admin/groups/${encodeURL(groupId, true)}`;
}
// private but used in test
handleAccessSaved() {
// Set a new 'original' value to keep track of after the value has been
// saved.
this.setOriginalRuleValues();
}
private handleEditingChanged(editingOld: boolean) {
// Ignore when editing gets set initially.
if (!editingOld) {
return;
}
// Restore original values if no longer editing.
if (!this.editing) {
this.handleUndoChange();
}
}
// private but used in test
computeSectionClass() {
const classList = [];
if (this.editing) {
classList.push('editing');
}
if (this.deleted) {
classList.push('deleted');
}
return classList.join(' ');
}
// private but used in test
computeForceOptions(action?: string) {
if (this.permission === AccessPermissionId.PUSH) {
if (action === Action.ALLOW) {
return ForcePushOptions.ALLOW;
} else if (action === Action.BLOCK) {
return ForcePushOptions.BLOCK;
} else {
return [];
}
} else if (this.permission === AccessPermissionId.EDIT_TOPIC_NAME) {
return FORCE_EDIT_OPTIONS;
}
return [];
}
// private but used in test
getDefaultRuleValues(): EditablePermissionRuleInfo {
if (this.permission === AccessPermissionId.PRIORITY) {
return {action: PRIORITY_OPTIONS[0]};
}
if (this.label) {
return {
action: DROPDOWN_OPTIONS[0],
min: this.label.values[0].value,
max: this.label.values[this.label.values.length - 1].value,
};
}
if (this.computeForce(Action.ALLOW)) {
return {
action: DROPDOWN_OPTIONS[0],
force: this.computeForceOptions(Action.ALLOW)[0].value,
};
}
return {action: DROPDOWN_OPTIONS[0]};
}
// private but used in test
setDefaultRuleValues() {
this.rule!.value = this.getDefaultRuleValues();
this.handleRuleChange();
}
// private but used in test
computeOptions() {
if (this.permission === 'priority') {
return PRIORITY_OPTIONS;
}
return DROPDOWN_OPTIONS;
}
private handleRemoveRule() {
if (!this.rule?.value) return;
if (this.rule.value.added) {
fireEvent(this, 'added-rule-removed');
}
this.deleted = true;
this.rule.value.deleted = true;
this.handleRuleChange();
fireEvent(this, 'access-modified');
}
private handleUndoRemove() {
if (!this.rule?.value) return;
this.deleted = false;
delete this.rule.value.deleted;
this.handleRuleChange();
}
private handleUndoChange() {
if (!this.originalRuleValues || !this.rule?.value) {
return;
}
// gr-permission will take care of removing rules that were added but
// unsaved. We need to keep the added bit for the filter.
if (this.rule.value.added) {
return;
}
this.rule.value = {...this.originalRuleValues};
this.deleted = false;
delete this.rule.value.deleted;
delete this.rule.value.modified;
this.handleRuleChange();
}
// private but used in test
handleValueChange() {
if (!this.originalRuleValues || !this.rule?.value) {
return;
}
this.rule.value.modified = true;
this.handleRuleChange();
// Allows overall access page to know a change has been made.
fireEvent(this, 'access-modified');
}
// private but used in test
setOriginalRuleValues() {
if (!this.rule?.value) return;
this.originalRuleValues = {...this.rule.value};
}
private handleActionBindValueChanged(e: BindValueChangeEvent) {
if (
!this.rule?.value ||
e.detail.value === undefined ||
this.rule.value.action === String(e.detail.value)
)
return;
this.rule.value.action = String(e.detail.value) as PermissionAction;
this.handleValueChange();
}
private handleMinBindValueChanged(e: BindValueChangeEvent) {
if (
!this.rule?.value ||
e.detail.value === undefined ||
this.rule.value.min === Number(e.detail.value)
)
return;
this.rule.value.min = Number(e.detail.value);
this.handleValueChange();
}
private handleMaxBindValueChanged(e: BindValueChangeEvent) {
if (
!this.rule?.value ||
e.detail.value === undefined ||
this.rule.value.max === Number(e.detail.value)
)
return;
this.rule.value.max = Number(e.detail.value);
this.handleValueChange();
}
private handleForceBindValueChanged(e: BindValueChangeEvent) {
const forceValue = String(e.detail.value) === 'true' ? true : false;
if (
!this.rule?.value ||
e.detail.value === undefined ||
this.rule.value.force === forceValue
)
return;
this.rule.value.force = forceValue;
this.handleValueChange();
}
private handleRuleChange() {
this.requestUpdate('rule');
this.dispatchEvent(
new CustomEvent('rule-changed', {
detail: {value: this.rule},
composed: true,
bubbles: true,
})
);
}
}