blob: d7ffbaf3d47ba6f5c4f75682f2fd50c2100c34ab [file] [log] [blame]
/**
* @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 '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
import '../../shared/gr-select/gr-select';
import '../../shared/gr-textarea/gr-textarea';
import {
AutocompleteSuggestion,
AutocompleteQuery,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {GroupId, GroupInfo, GroupName} from '../../../types/common';
import {firePageError, fireTitleChange} from '../../../utils/event-util';
import {appContext} from '../../../services/app-context';
import {ErrorCallback} from '../../../api/rest';
import {convertToString} from '../../../utils/string-util';
import {BindValueChangeEvent} from '../../../types/events';
import {fontStyles} from '../../../styles/gr-font-styles';
import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {subpageStyles} from '../../../styles/gr-subpage-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators';
const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
const OPTIONS = {
submitFalse: {
value: false,
label: 'False',
},
submitTrue: {
value: true,
label: 'True',
},
};
export interface GroupNameChangedDetail {
name: GroupName;
external: boolean;
}
declare global {
interface HTMLElementEventMap {
'text-changed': CustomEvent;
'value-changed': CustomEvent;
}
interface HTMLElementTagNameMap {
'gr-group': GrGroup;
}
}
@customElement('gr-group')
export class GrGroup extends LitElement {
/**
* Fired when the group name changes.
*
* @event name-changed
*/
private readonly query: AutocompleteQuery;
@property({type: String})
groupId?: GroupId;
@state() private originalOwnerName?: string;
@state() private originalDescriptionName?: string;
@state() private originalOptionsVisibleToAll?: boolean;
@state() private submitTypes = Object.values(OPTIONS);
/* private but used in test */
@state() isAdmin = false;
/* private but used in test */
@state() groupOwner = false;
/* private but used in test */
@state() groupIsInternal = false;
/* private but used in test */
@state() loading = true;
/* private but used in test */
@state() groupConfig?: GroupInfo;
/* private but used in test */
@state() groupConfigOwner?: string;
/* private but used in test */
@state() originalName?: GroupName;
private readonly restApiService = appContext.restApiService;
constructor() {
super();
this.query = (input: string) => this.getGroupSuggestions(input);
}
override connectedCallback() {
super.connectedCallback();
this.loadGroup();
}
static override get styles() {
return [
fontStyles,
formStyles,
sharedStyles,
subpageStyles,
css`
h3.edited:after {
color: var(--deemphasized-text-color);
content: ' *';
}
`,
];
}
override render() {
return html`
<div class="main gr-form-styles read-only">
<div id="loading" class="${this.computeLoadingClass()}">Loading...</div>
<div id="loadedContent" class="${this.computeLoadingClass()}">
<h1 id="Title" class="heading-1">
${convertToString(this.originalName)}
</h1>
<h2 id="configurations" class="heading-2">General</h2>
<div id="form">
<fieldset>
${this.renderGroupUUID()} ${this.renderGroupName()}
${this.renderGroupOwner()} ${this.renderGroupDescription()}
${this.renderGroupOptions()}
</fieldset>
</div>
</div>
</div>
`;
}
private renderGroupUUID() {
return html`
<h3 id="groupUUID" class="heading-3">Group UUID</h3>
<fieldset>
<gr-copy-clipboard
id="uuid"
.text=${this.getGroupUUID()}
></gr-copy-clipboard>
</fieldset>
`;
}
private renderGroupName() {
const groupNameEdited = this.originalName !== this.groupConfig?.name;
return html`
<h3
id="groupName"
class="heading-3 ${this.computeHeaderClass(groupNameEdited)}"
>
Group Name
</h3>
<fieldset>
<span class="value">
<gr-autocomplete
id="groupNameInput"
.text=${convertToString(this.groupConfig?.name)}
?disabled=${this.computeGroupDisabled()}
@text-changed=${this.handleNameTextChanged}
></gr-autocomplete>
</span>
<span class="value" ?disabled=${this.computeGroupDisabled()}>
<gr-button
id="inputUpdateNameBtn"
?disabled=${!groupNameEdited}
@click=${this.handleSaveName}
>
Rename Group</gr-button
>
</span>
</fieldset>
`;
}
private renderGroupOwner() {
const groupOwnerNameEdited =
this.originalOwnerName !== this.groupConfig?.owner;
return html`
<h3
id="groupOwner"
class="heading-3 ${this.computeHeaderClass(groupOwnerNameEdited)}"
>
Owners
</h3>
<fieldset>
<span class="value">
<gr-autocomplete
id="groupOwnerInput"
.text=${convertToString(this.groupConfig?.owner)}
.value=${convertToString(this.groupConfigOwner)}
.query=${this.query}
?disabled=${this.computeGroupDisabled()}
@text-changed=${this.handleOwnerTextChanged}
@value-changed=${this.handleOwnerValueChanged}
>
</gr-autocomplete>
</span>
<span class="value" ?disabled=${this.computeGroupDisabled()}>
<gr-button
id="inputUpdateOwnerBtn"
?disabled=${!groupOwnerNameEdited}
@click=${this.handleSaveOwner}
>
Change Owners</gr-button
>
</span>
</fieldset>
`;
}
private renderGroupDescription() {
const groupDescriptionEdited =
this.originalDescriptionName !== this.groupConfig?.description;
return html`
<h3 class="heading-3 ${this.computeHeaderClass(groupDescriptionEdited)}">
Description
</h3>
<fieldset>
<div>
<gr-textarea
class="description"
autocomplete="on"
rows="4"
monospace
?disabled=${this.computeGroupDisabled()}
.text=${convertToString(this.groupConfig?.description)}
@text-changed=${this.handleDescriptionTextChanged}
>
</div>
<span class="value" ?disabled=${this.computeGroupDisabled()}>
<gr-button
?disabled=${!groupDescriptionEdited}
@click=${this.handleSaveDescription}
>
Save Description
</gr-button>
</span>
</fieldset>
`;
}
private renderGroupOptions() {
const groupOptionsEdited =
this.originalOptionsVisibleToAll !==
this.groupConfig?.options?.visible_to_all;
return html`
<h3
id="options"
class="heading-3 ${this.computeHeaderClass(groupOptionsEdited)}"
>
Group Options
</h3>
<fieldset>
<section>
<span class="title">
Make group visible to all registered users
</span>
<span class="value">
<gr-select
id="visibleToAll"
.bindValue="${this.groupConfig?.options?.visible_to_all}"
@bind-value-changed=${this.handleOptionsBindValueChanged}
>
<select ?disabled=${this.computeGroupDisabled()}>
${this.submitTypes.map(
item => html`
<option value=${item.value}>${item.label}</option>
`
)}
</select>
</gr-select>
</span>
</section>
<span class="value" ?disabled=${this.computeGroupDisabled()}>
<gr-button
?disabled=${!groupOptionsEdited}
@click=${this.handleSaveOptions}
>
Save Group Options
</gr-button>
</span>
</fieldset>
`;
}
/* private but used in test */
async loadGroup() {
if (!this.groupId) {
return;
}
const promises: Promise<unknown>[] = [];
const errFn: ErrorCallback = response => {
firePageError(response);
};
const config = await this.restApiService.getGroupConfig(
this.groupId,
errFn
);
if (!config || !config.name) return;
if (config.description === undefined) {
config.description = '';
}
this.originalName = config.name;
this.originalOwnerName = config.owner;
this.originalDescriptionName = config.description;
this.groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
promises.push(
this.restApiService.getIsAdmin().then(isAdmin => {
this.isAdmin = !!isAdmin;
})
);
promises.push(
this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
this.groupOwner = !!isOwner;
})
);
// If visible to all is undefined, set to false. If it is defined
// as false, setting to false is fine. If any optional values
// are added with a default of true, then this would need to be an
// undefined check and not a truthy/falsy check.
if (config.options && !config.options.visible_to_all) {
config.options.visible_to_all = false;
}
this.groupConfig = config;
this.originalOptionsVisibleToAll = config?.options?.visible_to_all;
fireTitleChange(this, config.name);
await Promise.all(promises);
this.loading = false;
}
/* private but used in test */
computeLoadingClass() {
return this.loading ? 'loading' : '';
}
/* private but used in test */
async handleSaveName() {
const groupConfig = this.groupConfig;
if (!this.groupId || !groupConfig || !groupConfig.name) {
return Promise.reject(new Error('invalid groupId or config name'));
}
const groupName = groupConfig.name;
const config = await this.restApiService.saveGroupName(
this.groupId,
groupName
);
if (config.status === 200) {
this.originalName = groupName;
const detail: GroupNameChangedDetail = {
name: groupName,
external: !this.groupIsInternal,
};
this.dispatchEvent(
new CustomEvent('name-changed', {
detail,
composed: true,
bubbles: true,
})
);
this.requestUpdate();
}
return;
}
/* private but used in test */
async handleSaveOwner() {
if (!this.groupId || !this.groupConfig) return;
let owner = this.groupConfig.owner;
if (this.groupConfigOwner) {
owner = decodeURIComponent(this.groupConfigOwner);
}
if (!owner) return;
await this.restApiService.saveGroupOwner(this.groupId, owner);
this.originalOwnerName = this.groupConfig?.owner;
this.groupConfigOwner = undefined;
}
/* private but used in test */
async handleSaveDescription() {
if (
!this.groupId ||
!this.groupConfig ||
this.groupConfig.description === undefined
)
return;
await this.restApiService.saveGroupDescription(
this.groupId,
this.groupConfig.description
);
this.originalDescriptionName = this.groupConfig.description;
}
/* private but used in test */
async handleSaveOptions() {
if (!this.groupId || !this.groupConfig || !this.groupConfig.options) return;
const visible = this.groupConfig.options.visible_to_all;
const options = {visible_to_all: visible};
await this.restApiService.saveGroupOptions(this.groupId, options);
this.originalOptionsVisibleToAll =
this.groupConfig?.options?.visible_to_all;
}
private computeHeaderClass(configChanged: boolean) {
return configChanged ? 'edited' : '';
}
private getGroupSuggestions(input: string) {
return this.restApiService.getSuggestedGroups(input).then(response => {
const groups: AutocompleteSuggestion[] = [];
for (const [name, group] of Object.entries(response ?? {})) {
groups.push({name, value: decodeURIComponent(group.id)});
}
return groups;
});
}
/* private but used in test */
computeGroupDisabled() {
return !(this.groupIsInternal && (this.isAdmin || this.groupOwner));
}
private getGroupUUID() {
const id = this.groupConfig?.id;
if (!id) return;
return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
}
private handleNameTextChanged(e: CustomEvent) {
if (!this.groupConfig || this.loading) return;
this.groupConfig.name = e.detail.value as GroupName;
this.requestUpdate();
}
private handleOwnerTextChanged(e: CustomEvent) {
if (!this.groupConfig || this.loading) return;
this.groupConfig.owner = e.detail.value;
this.requestUpdate();
}
private handleOwnerValueChanged(e: CustomEvent) {
if (this.loading) return;
this.groupConfigOwner = e.detail.value;
this.requestUpdate();
}
private handleDescriptionTextChanged(e: CustomEvent) {
if (!this.groupConfig || this.loading) return;
this.groupConfig.description = e.detail.value;
this.requestUpdate();
}
private handleOptionsBindValueChanged(e: BindValueChangeEvent) {
if (!this.groupConfig || !this.groupConfig.options || this.loading) return;
this.groupConfig.options.visible_to_all = e.detail
.value as unknown as boolean;
this.requestUpdate();
}
}