| /** |
| * @license |
| * Copyright (C) 2016 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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea'; |
| import '../../../styles/shared-styles'; |
| import '../gr-button/gr-button'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {customElement, property} from '@polymer/decorators'; |
| import {htmlTemplate} from './gr-editable-content_html'; |
| import {fireAlert, fireEvent} from '../../../utils/event-util'; |
| import {getAppContext} from '../../../services/app-context'; |
| import {debounce, DelayedTask} from '../../../utils/async-util'; |
| import {queryAndAssert} from '../../../utils/common-util'; |
| import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea'; |
| import {Interaction} from '../../../constants/reporting'; |
| |
| const RESTORED_MESSAGE = 'Content restored from a previous edit.'; |
| const STORAGE_DEBOUNCE_INTERVAL_MS = 400; |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-editable-content': GrEditableContent; |
| } |
| } |
| |
| @customElement('gr-editable-content') |
| export class GrEditableContent extends PolymerElement { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| /** |
| * Fired when the save button is pressed. |
| * |
| * @event editable-content-save |
| */ |
| |
| /** |
| * Fired when the cancel button is pressed. |
| * |
| * @event editable-content-cancel |
| */ |
| |
| /** |
| * Fired when content is restored from storage. |
| * |
| * @event show-alert |
| */ |
| |
| @property({type: String, notify: true, observer: '_contentChanged'}) |
| content?: string; |
| |
| @property({type: Boolean, reflectToAttribute: true}) |
| disabled = false; |
| |
| @property({type: Boolean, observer: '_editingChanged', notify: true}) |
| editing = false; |
| |
| @property({type: Boolean}) |
| removeZeroWidthSpace?: boolean; |
| |
| // If no storage key is provided, content is not stored. |
| @property({type: String}) |
| storageKey?: string; |
| |
| /** If false, then the "Show more" button was used to expand. */ |
| @property({type: Boolean}) |
| _commitCollapsed = true; |
| |
| @property({type: Boolean}) |
| commitCollapsible = true; |
| |
| @property({ |
| type: Boolean, |
| computed: |
| '_computeHideShowAllContainer(hideEditCommitMessage, _hideShowAllButton, editing)', |
| }) |
| _hideShowAllContainer = false; |
| |
| @property({ |
| type: Boolean, |
| computed: '_computeHideShowAllButton(commitCollapsible, editing)', |
| }) |
| _hideShowAllButton = false; |
| |
| @property({type: Boolean}) |
| hideEditCommitMessage?: boolean; |
| |
| @property({ |
| type: Boolean, |
| computed: '_computeSaveDisabled(disabled, content, _newContent)', |
| }) |
| _saveDisabled!: boolean; |
| |
| @property({type: String, observer: '_newContentChanged'}) |
| _newContent = ''; |
| |
| private readonly storage = getAppContext().storageService; |
| |
| private readonly reporting = getAppContext().reportingService; |
| |
| // Tests use this so needs to be non private |
| storeTask?: DelayedTask; |
| |
| override disconnectedCallback() { |
| this.storeTask?.cancel(); |
| super.disconnectedCallback(); |
| } |
| |
| _contentChanged() { |
| /* A changed content means that either a different change has been loaded |
| * or new content was saved. Either way, let's reset the component. |
| */ |
| this.editing = false; |
| this._newContent = ''; |
| } |
| |
| focusTextarea() { |
| queryAndAssert<IronAutogrowTextareaElement>( |
| this, |
| 'iron-autogrow-textarea' |
| ).textarea.focus(); |
| } |
| |
| _newContentChanged(newContent: string) { |
| if (!this.storageKey) return; |
| const storageKey = this.storageKey; |
| |
| this.storeTask = debounce( |
| this.storeTask, |
| () => { |
| if (newContent.length) { |
| this.storage.setEditableContentItem(storageKey, newContent); |
| } else { |
| // This does not really happen, because we don't clear newContent |
| // after saving (see below). So this only occurs when the user clears |
| // all the content in the editable textarea. But GrStorage cleans |
| // up itself after one day, so we are not so concerned about leaving |
| // some garbage behind. |
| this.storage.eraseEditableContentItem(storageKey); |
| } |
| }, |
| STORAGE_DEBOUNCE_INTERVAL_MS |
| ); |
| } |
| |
| _editingChanged(editing: boolean) { |
| // This method is for initializing _newContent when you start editing. |
| // Restoring content from local storage is not perfect and has |
| // some issues: |
| // |
| // 1. When you start editing in multiple tabs, then we are vulnerable to |
| // race conditions between the tabs. |
| // 2. The stored content is keyed by revision, so when you upload a new |
| // patchset and click "reload" and then click "cancel" on the content- |
| // editable, then you won't be able to recover the content anymore. |
| // |
| // Because of these issues we believe that it is better to only recover |
| // content from local storage when you enter editing mode for the first |
| // time. Otherwise it is better to just keep the last editing state from |
| // the same session. |
| if (!editing || this._newContent) return; |
| |
| let content; |
| if (this.storageKey) { |
| const storedContent = this.storage.getEditableContentItem( |
| this.storageKey |
| ); |
| if (storedContent?.message) { |
| content = storedContent.message; |
| fireAlert(this, RESTORED_MESSAGE); |
| } |
| } |
| if (!content) { |
| content = this.content || ''; |
| } |
| |
| // TODO(wyatta) switch linkify sequence, see issue 5526. |
| this._newContent = this.removeZeroWidthSpace |
| ? content.replace(/^R=\u200B/gm, 'R=') |
| : content; |
| } |
| |
| _computeSaveDisabled( |
| disabled?: boolean, |
| content?: string, |
| newContent?: string |
| ): boolean { |
| return disabled || !newContent || content === newContent; |
| } |
| |
| _handleSave(e: Event) { |
| e.preventDefault(); |
| this.dispatchEvent( |
| new CustomEvent('editable-content-save', { |
| detail: {content: this._newContent}, |
| composed: true, |
| bubbles: true, |
| }) |
| ); |
| // It would be nice, if we would set this._newContent = undefined here, |
| // but we can only do that when we are sure that the save operation has |
| // succeeded. |
| } |
| |
| _handleCancel(e: Event) { |
| e.preventDefault(); |
| this.editing = false; |
| fireEvent(this, 'editable-content-cancel'); |
| } |
| |
| _computeCollapseText(collapsed: boolean) { |
| return collapsed ? 'Show all' : 'Show less'; |
| } |
| |
| _toggleCommitCollapsed() { |
| this._commitCollapsed = !this._commitCollapsed; |
| this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, { |
| sectionName: 'Commit message', |
| toState: !this._commitCollapsed ? 'Show all' : 'Show less', |
| }); |
| if (this._commitCollapsed) { |
| window.scrollTo(0, 0); |
| } |
| } |
| |
| _computeHideShowAllContainer( |
| hideEditCommitMessage?: boolean, |
| _hideShowAllButton?: boolean, |
| editing?: boolean |
| ) { |
| if (editing) return false; |
| return _hideShowAllButton && hideEditCommitMessage; |
| } |
| |
| _computeHideShowAllButton(commitCollapsible?: boolean, editing?: boolean) { |
| return !commitCollapsible || editing; |
| } |
| |
| _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) { |
| return collapsible && collapsed; |
| } |
| |
| _handleEditCommitMessage() { |
| this.editing = true; |
| this.focusTextarea(); |
| } |
| } |