Migrate UI component to Lit Change-Id: I6342238eac9cd263bc0948b27ea47ac39c4b5fc2
diff --git a/BUILD b/BUILD index 95ed97f..043a44d 100644 --- a/BUILD +++ b/BUILD
@@ -18,16 +18,10 @@ "Implementation-Title: Plugin messageoftheday", "Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/messageoftheday", ], - resource_jars = [":gr-messageoftheday"], + resource_jars = ["//plugins/messageoftheday/web:messageoftheday"], resources = glob(["src/main/resources/**/*"]), ) -gerrit_js_bundle( - name = "gr-messageoftheday", - srcs = glob(["gr-messageoftheday/*.js"]), - entry_point = "gr-messageoftheday/plugin.js", -) - junit_tests( name = "messageoftheday_tests", srcs = glob(["src/test/java/**/*.java"]),
diff --git a/gr-messageoftheday/gr-messageoftheday-banner.js b/gr-messageoftheday/gr-messageoftheday-banner.js deleted file mode 100644 index dc9e393..0000000 --- a/gr-messageoftheday/gr-messageoftheday-banner.js +++ /dev/null
@@ -1,74 +0,0 @@ -/** - * @license - * Copyright (C) 2021 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 {htmlTemplate} from './gr-messageoftheday-banner_html.js'; - -class GrMessageOfTheDayBanner extends Polymer.Element { - static get is() { - return 'gr-messageoftheday-banner'; - } - - static get template() { - return htmlTemplate; - } - - static get properties() { - return { - _message: Object, - _hidden: { - type: Boolean, - value: true, - } - }; - } - - connectedCallback() { - super.connectedCallback(); - - this.plugin.restApi() - .get(`/config/server/${this.plugin.getPluginName()}~message`) - .then(message => { - if (!message || !message.html) { - return; - } - this._message = message; - this._isHidden(); - this.$.message.innerHTML = this._message.html; - }); - } - - _handleDismissMessage() { - document.cookie = - `msg-${this._message.content_id}=1; path=/; expires=${this._getExpires()}`; - this._hidden = true; - } - - _isHidden() { - this._hidden = document.cookie.search(`msg-${this._message.content_id}=`) > -1; - } - - _getExpires() { - var date = new Date(); - date.setHours(0); - date.setMinutes(0); - date.setSeconds(0); - date.setMilliseconds(0); - date.setDate(date.getDate() + 1); - return date.toUTCString(); - } -} - -customElements.define(GrMessageOfTheDayBanner.is, GrMessageOfTheDayBanner);
diff --git a/gr-messageoftheday/gr-messageoftheday-banner_html.js b/gr-messageoftheday/gr-messageoftheday-banner_html.js deleted file mode 100644 index e5a2661..0000000 --- a/gr-messageoftheday/gr-messageoftheday-banner_html.js +++ /dev/null
@@ -1,37 +0,0 @@ -/** - * @license - * Copyright (C) 2021 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. - */ - -export const htmlTemplate = Polymer.html` -<style include="shared-styles"> - #container { - background-color: var(--line-item-highlight-color); - display: flex; - height: fit-content; - justify-content: space-between; - padding: 1em; - } - #message { - flex-grow: 1; - } -</style> -<div id="container" hidden$="[[_hidden]]"> - <div id="message"></div> - <gr-button id="dismissMessageBtn" - link - on-click="_handleDismissMessage">Dismiss</gr-button> -</div> -<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>`;
diff --git a/gr-messageoftheday/gr-messageoftheday-edit.js b/gr-messageoftheday/gr-messageoftheday-edit.js deleted file mode 100644 index 3442e6a..0000000 --- a/gr-messageoftheday/gr-messageoftheday-edit.js +++ /dev/null
@@ -1,170 +0,0 @@ -/** - * @license - * Copyright (C) 2024 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 {htmlTemplate} from './gr-messageoftheday-edit_html.js'; - -class GrMessageOfTheDayEdit extends Polymer.Element { - static get is() { - return 'gr-messageoftheday-edit'; - } - - static get template() { - return htmlTemplate; - } - - static get properties() { - return { - _message: { - type: String, - observer: '_messageChanged' - }, - _expire_after_value: { - type: String, - }, - _expire_after_unit: { - type: String, - value: 'd', - }, - _can_update: { - type: Boolean, - value: false, - }, - _show_update_banner: { - type: Boolean, - value: false, - }, - }; - } - - connectedCallback() { - super.connectedCallback(); - } - - ready() { - super.ready(); - this._canUpdate(); - } - - _canUpdate() { - const endpoint = `/accounts/self/capabilities?q=messageoftheday-updateBanner`; - return this.plugin.restApi().get(endpoint).then(response => { - if (response && response['messageoftheday-updateBanner'] === true) { - this._can_update = true; - this._fetchMessage(); - } - }).catch(error => { - console.error('Error checking updateBanner capability:', error); - this._can_update = false; - }); - } - - _fetchMessage() { - return this.plugin.restApi().get("/config/server/messageoftheday~message").then(response => { - if (response) { - this._message = response.html; - } else { - this._message = ''; - } - }).catch(error => { - console.error('Error fetching message:', error); - this._message = ''; - }); - } - - _saveMessage() { - const endpoint = `/config/server/messageoftheday~message`; - const payload = { - message: this._message - }; - if (this._expire_after_value) { - payload.expires_at = this._convertToFormattedDate( - this._expire_after_value, this._expire_after_unit); - } - - return this.plugin.restApi().post(endpoint, payload).then( - response => { - location.reload(); - } - ).catch(error => { - console.error('Error saving message:', error); - }); - } - - _openDialog() { - if (!this.$.message_dialog_overlay.open) { - this.$.message_dialog_overlay.showModal(); - } - this.$.message_dialog.classList.toggle('invisible', false); - } - - _closeDialog() { - this.$.message_dialog.classList.toggle('invisible', true); - this.$.message_dialog_overlay.close(); - } - - _onMessageInput(e) { - this._message = e.detail.value; - } - - _onExpireAfterValueInput(e) { - const target = e.target; - this._expire_after_value = target.value ?? ''; - } - - _onExpireAfterUnitChange(e) { - const sel = e.target; - this._expire_after_unit = sel.value ?? 'd'; - } - - _messageChanged(newMessage) { - const messagePreview = this.shadowRoot.querySelector('#messagePreview'); - if (messagePreview) { - messagePreview.innerHTML = newMessage; - } - } - - _convertToFormattedDate(value, unit) { - let msToAdd = 0; - switch (unit) { - case 'm': - msToAdd = value * 60 * 1000; - break; - case 'h': - msToAdd = value * 60 * 60 * 1000; - break; - case 'd': - msToAdd = value * 24 * 60 * 60 * 1000; - break; - case 'w': - msToAdd = value * 7 * 24 * 60 * 60 * 1000; - break; - } - - const now = new Date(); - const future = new Date(now.getTime() + msToAdd); - const options = { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short' - }; - return new Intl.DateTimeFormat('en-US', options).format(future); - } -} - -customElements.define(GrMessageOfTheDayEdit.is, GrMessageOfTheDayEdit);
diff --git a/gr-messageoftheday/gr-messageoftheday-edit_html.js b/gr-messageoftheday/gr-messageoftheday-edit_html.js deleted file mode 100644 index dacaa45..0000000 --- a/gr-messageoftheday/gr-messageoftheday-edit_html.js +++ /dev/null
@@ -1,131 +0,0 @@ -/** - * @license - * Copyright (C) 2024 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. - */ - -export const htmlTemplate = Polymer.html` - <style include="gr-modal-styles gr-material-styles"> - input, select { - background-color: var(--select-background-color); - color: var(--primary-text-color); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - padding: var(--spacing-s); - font: inherit; - } - gr-autogrow-textarea, #messagePreview { - background-color: var(--view-background-color); - color: var(--primary-text-color); - font: inherit; - width: 80ch; - height: 25ch; - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - box-sizing: border-box; - } - gr-autogrow-textarea:focus-within { - border: 2px solid var(--input-focus-border-color); - } - #messagePreview { - background-color: var(--background-color-tertiary); - overflow-y: auto; - } - section { - margin-bottom: 1em; - } - md-icon-button { - --md-sys-color-on-surface-variant: var(--header-text-color); - } - md-icon[filled] { - font-variation-settings: 'FILL' 1; - } - .value { - display: flex; - flex-direction: column; - margin-bottom: 10px; - } - .value > * { - margin: 0; - } - .icon-button { - background: none; - box-shadow: none; - padding: 0; - min-width: 0; - } - - </style> - <template is="dom-if" if="[[_can_update]]"> - <md-icon-button class="icon-button" on-click="_openDialog"> - <md-icon filled>campaign</md-icon> - </md-icon-button> - </template> - <dialog id="message_dialog_overlay" tabindex="-1"> - <gr-dialog id="message_dialog" confirm-label="Save Message" - on-confirm="_saveMessage" on-cancel="_closeDialog"> - <div class="header" slot="header">Set Banner Message</div> - <div class="main" slot="main"> - <section> - <span class="title">Expire After:</span> - <span class="value"> - <div style="display: flex; align-items: center; gap: 5px;"> - <md-outlined-text-field - id="expireAfterInput" - class="showBlueFocusBorder" - placeholder="Enter Number" - value="[[_expire_after_value]]" - on-input="_onExpireAfterValueInput"> - </md-outlined-text-field> - <md-outlined-select - id="expireAfterUnitSelect" - value="[[_expire_after_unit]]" - on-change="_onExpireAfterUnitChange"> - <md-select-option value="m"> - <div slot="headline">minutes</div> - </md-select-option> - <md-select-option value="h"> - <div slot="headline">hours</div> - </md-select-option> - <md-select-option value="d"> - <div slot="headline">days</div> - </md-select-option> - <md-select-option value="w"> - <div slot="headline">weeks</div> - </md-select-option> - </md-outlined-select> - </div> - </span> - </section> - <section> - <span class="title">Message:</span> - <span class="value"> - <gr-autogrow-textarea - class="text_area" - placeholder="Enter Message" - autocomplete="off" - value="[[_message]]" - on-input="_onMessageInput"/> - </span> - </section> - <section> - <span class="title">Preview:</span> - <span class="value"> - <div id="messagePreview" readonly>{{_message}}</div> - </span> - </section> - </div> - </gr-dialog> - </dialog> -`;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/messageoftheday/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/messageoftheday/HttpModule.java index 4a43938..911da13 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/messageoftheday/HttpModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/messageoftheday/HttpModule.java
@@ -23,6 +23,6 @@ @Override protected void configure() { DynamicSet.bind(binder(), WebUiPlugin.class) - .toInstance(new JavaScriptPlugin("gr-messageoftheday.js")); + .toInstance(new JavaScriptPlugin("messageoftheday.js")); } }
diff --git a/web/BUILD b/web/BUILD new file mode 100644 index 0000000..ed44db1 --- /dev/null +++ b/web/BUILD
@@ -0,0 +1,64 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("//tools/bzl:js.bzl", "web_test_runner") +load("@com_googlesource_gerrit_bazlets//js:defs.bzl", "gerrit_js_bundle") +load("//tools/js:eslint.bzl", "plugin_eslint") + +package_group( + name = "visibility", + packages = ["//plugins/messageoftheday/..."], +) + +package(default_visibility = [":visibility"]) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//plugins:tsconfig-plugins-base", + ], +) + +ts_project( + name = "messageoftheday-ts", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*test*"], + ), + incremental = True, + out_dir = "_bazel_ts_out", + tsconfig = ":tsconfig", + deps = [ + "//plugins:node_modules", + ], +) + +ts_project( + name = "messageoftheday-ts-tests", + srcs = glob(["**/*.ts"]), + incremental = True, + out_dir = "_bazel_ts_out_tests", + tsconfig = ":tsconfig", + deps = [ + "//plugins:node_modules", + "//polygerrit-ui:node_modules", + ], +) + +gerrit_js_bundle( + name = "messageoftheday", + srcs = [":messageoftheday-ts"], + entry_point = "_bazel_ts_out/plugin.js", +) + +web_test_runner( + name = "web_test_runner", + srcs = ["web_test_runner.sh"], + data = [ + ":messageoftheday-ts-tests", + ":tsconfig", + "//plugins:node_modules", + "//polygerrit-ui:node_modules", + ], +) + +plugin_eslint()
diff --git a/web/gr-messageoftheday-banner.ts b/web/gr-messageoftheday-banner.ts new file mode 100644 index 0000000..5febc28 --- /dev/null +++ b/web/gr-messageoftheday-banner.ts
@@ -0,0 +1,134 @@ +/** + * @license + * Copyright (C) 2026 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 { PluginApi } from '@gerritcodereview/typescript-api/plugin'; +import { css, CSSResult, html, LitElement } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; + +declare global { + interface HTMLElementTagNameMap { + 'gr-messageoftheday-banner': GrMessageOfTheDayBanner; + } +} + +interface Message { + html?: string; + content_id?: string; +} + +@customElement('gr-messageoftheday-banner') +export class GrMessageOfTheDayBanner extends LitElement { + @query('#message') + messageElement?: HTMLDivElement; + + @property({ type: Object }) + plugin!: PluginApi; + + @state() + private message?: Message; + + @state() + private isHidden = true; + + static override get styles() { + return [ + window.Gerrit?.styles.font as CSSResult, + css` + #container { + background-color: var(--line-item-highlight-color); + display: flex; + height: fit-content; + justify-content: space-between; + align-items: center; + padding: 1em; + } + #message { + flex-grow: 1; + } + `, + ]; + } + + override connectedCallback() { + super.connectedCallback(); + this.fetchMessage(); + } + + private async fetchMessage() { + this.plugin + .restApi() + .get<Message>( + `/config/server/${this.plugin.getPluginName()}~message` + ) + .then(message => { + if (!message || !message.html) { + return; + } + + this.message = message; + this.checkIsHidden(); + + // Wait for render then set innerHTML + this.updateComplete.then(() => { + if (this.messageElement && this.message?.html) { + this.messageElement.innerHTML = this.message.html; + } + }) + + }) + .catch(error => { + console.error('Error fetching message:', error); + }) + } + + override render() { + if (this.isHidden) { + return html``; + } + + return html` + <div id="container"> + <div id="message"></div> + <gr-button id="dismissMessageBtn" link @click="${this.handleDismiss}"> + Dismiss + </gr-button> + </div> + `; + } + + private handleDismiss() { + if (!this.message?.content_id) return; + + document.cookie = `msg-${this.message.content_id}=1; path=/; expires=${this.getExpires()}`; + this.isHidden = true; + } + + private checkIsHidden() { + if (!this.message?.content_id) { + this.isHidden = true; + return; + } + this.isHidden = + document.cookie.search(`msg-${this.message.content_id}=`) > -1; + } + + private getExpires(): string { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() + 1); + return date.toUTCString(); + } +}
diff --git a/web/gr-messageoftheday-banner_test.ts b/web/gr-messageoftheday-banner_test.ts new file mode 100644 index 0000000..00da384 --- /dev/null +++ b/web/gr-messageoftheday-banner_test.ts
@@ -0,0 +1,128 @@ +/** + * @license + * Copyright (C) 2026 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 './test/test-setup'; +import './gr-messageoftheday-banner'; +import {GrMessageOfTheDayBanner} from './gr-messageoftheday-banner'; +import {PluginApi} from '@gerritcodereview/typescript-api/plugin'; +import {assert} from '@open-wc/testing'; + +suite('gr-messageoftheday-banner tests', () => { + let element: GrMessageOfTheDayBanner; + let mockGetResponse: any; + + function queryInShadow<T extends HTMLElement>( + el: HTMLElement, + selector: string + ): T | null { + return el.shadowRoot?.querySelector<T>(selector) || null; + } + + async function createElement(): Promise<GrMessageOfTheDayBanner> { + const el = document.createElement( + 'gr-messageoftheday-banner' + ) as GrMessageOfTheDayBanner; + + // Set properties BEFORE adding to DOM + el.plugin = { + getPluginName: () => 'messageoftheday', + restApi: () => { + return { + get: async () => mockGetResponse, + }; + }, + } as unknown as PluginApi; + + // Add to DOM + document.body.appendChild(el); + + await el.updateComplete; + // Wait for async fetch + await new Promise(resolve => setTimeout(resolve, 50)); + await el.updateComplete; + return el; + } + + teardown(() => { + if (element && element.parentNode) { + element.parentNode.removeChild(element); + } + // Clear cookies + document.cookie = 'msg-test123=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + }); + + test('does not render when no message', async () => { + mockGetResponse = {}; + element = await createElement(); + + const container = queryInShadow(element, '#container'); + assert.isNull(container, 'container should not exist'); + }); + + test('renders message when available', async () => { + mockGetResponse = { + html: '<p>Test message</p>', + content_id: 'test123', + }; + element = await createElement(); + + const container = queryInShadow(element, '#container'); + assert.isNotNull(container, 'container should exist'); + + const message = queryInShadow(element, '#message'); + assert.isNotNull(message, 'message div should exist'); + assert.include(message!.innerHTML, 'Test message'); + + // Verify HTML is actually rendered (not escaped) + const paragraph = message!.querySelector('p'); + assert.isNotNull(paragraph, 'HTML should be rendered as DOM elements'); + assert.equal(paragraph!.textContent, 'Test message'); + }); + + test('hides banner when dismiss button clicked', async () => { + mockGetResponse = { + html: '<p>Test message</p>', + content_id: 'test123', + }; + element = await createElement(); + + let container = queryInShadow(element, '#container'); + assert.isNotNull(container, 'container should exist initially'); + + const dismissBtn = queryInShadow<HTMLElement>(element, '#dismissMessageBtn'); + assert.isNotNull(dismissBtn, 'dismiss button should exist'); + + dismissBtn!.click(); + await element.updateComplete; + + container = queryInShadow(element, '#container'); + assert.isNull(container, 'container should be hidden after dismiss'); + }); + + test('respects cookie to hide message', async () => { + // Set cookie before creating element + document.cookie = 'msg-test123=1; path=/'; + + mockGetResponse = { + html: '<p>Test message</p>', + content_id: 'test123', + }; + element = await createElement(); + + const container = queryInShadow(element, '#container'); + assert.isNull(container, 'container should not exist when cookie is set'); + }); +});
diff --git a/web/gr-messageoftheday-edit.ts b/web/gr-messageoftheday-edit.ts new file mode 100644 index 0000000..c7c6b99 --- /dev/null +++ b/web/gr-messageoftheday-edit.ts
@@ -0,0 +1,320 @@ +/** + * @license + * Copyright (C) 2026 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 { PluginApi } from '@gerritcodereview/typescript-api/plugin'; +import { css, CSSResult, html, LitElement } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; + +declare global { + interface HTMLElementTagNameMap { + 'gr-messageoftheday-edit': GrMessageOfTheDayEdit; + } +} + +interface MessageResponse { + html?: string; +} + +interface CapabilitiesResponse { + 'messageoftheday-updateBanner'?: boolean; +} + +type ExpireUnit = 'm' | 'h' | 'd' | 'w'; + +@customElement('gr-messageoftheday-edit') +export class GrMessageOfTheDayEdit extends LitElement { + @query('#message_dialog_overlay') + dialogOverlay?: HTMLDialogElement; + + @query('#message_dialog') + dialog?: HTMLElement; + + @query('#messagePreview') + messagePreview?: HTMLDivElement; + + @property({ type: Object }) + plugin!: PluginApi; + + @state() + private message = ''; + + @state() + private expireAfterValue = ''; + + @state() + private expireAfterUnit: ExpireUnit = 'd'; + + @state() + private canUpdate = false; + + static override get styles() { + return [ + window.Gerrit?.styles.font as CSSResult, + window.Gerrit?.styles.form as CSSResult, + window.Gerrit?.styles.modal as CSSResult, + css` + input, + select { + background-color: var(--select-background-color); + color: var(--primary-text-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--spacing-s); + font: inherit; + } + gr-autogrow-textarea, + #messagePreview { + background-color: var(--view-background-color); + color: var(--primary-text-color); + font: inherit; + width: 80ch; + height: 25ch; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-sizing: border-box; + } + gr-autogrow-textarea:focus-within { + border: 2px solid var(--input-focus-border-color); + } + #messagePreview { + background-color: var(--background-color-tertiary); + overflow-y: auto; + padding: var(--spacing-s); + } + section { + margin-bottom: 1em; + } + .icon-button { + background: none; + box-shadow: none; + padding: 0; + min-width: 0; + border: none; + cursor: pointer; + color: var(--header-text-color); + } + .value { + display: flex; + flex-direction: column; + margin-bottom: 10px; + } + .value > * { + margin: 0; + } + .expire-inputs { + display: flex; + align-items: center; + gap: 5px; + } + `, + ]; + } + + override connectedCallback() { + super.connectedCallback(); + this.checkCanUpdate(); + } + + private async checkCanUpdate() { + this.plugin + .restApi() + .get<CapabilitiesResponse>( + '/accounts/self/capabilities?q=messageoftheday-updateBanner' + ) + .then(response => { + if (response && response['messageoftheday-updateBanner'] === true) { + this.canUpdate = true; + this.fetchMessage(); + } + }) + .catch(error => { + console.error('Error checking updateBanner capability:', error); + this.canUpdate = false; + }) + } + + private async fetchMessage() { + this.plugin + .restApi() + .get<MessageResponse>('/config/server/messageoftheday~message') + .then(response => { + this.message = response?.html || ''; + }) + .catch(error => { + console.error('Error fetching message:', error); + this.message = ''; + }) + } + + override render() { + if (!this.canUpdate) { + return html``; + } + + return html` + <button class="icon-button" @click="${this.openDialog}"> + 📢 + </button> + <dialog id="message_dialog_overlay" tabindex="-1"> + <gr-dialog + id="message_dialog" + confirm-label="Save Message" + @confirm="${this.saveMessage}" + @cancel="${this.closeDialog}" + > + <div class="header" slot="header">Set Banner Message</div> + <div class="main" slot="main"> + <section> + <span class="title">Expire After:</span> + <span class="value"> + <div class="expire-inputs"> + <input + id="expireAfterInput" + type="number" + placeholder="Enter Number" + .value="${this.expireAfterValue}" + @input="${this.handleExpireValueInput}" + /> + <select + id="expireAfterUnitSelect" + .value="${this.expireAfterUnit}" + @change="${this.handleExpireUnitChange}" + > + <option value="m">minutes</option> + <option value="h">hours</option> + <option value="d" ?selected="${this.expireAfterUnit === 'd'}"> + days + </option> + <option value="w">weeks</option> + </select> + </div> + </span> + </section> + <section> + <span class="title">Message:</span> + <span class="value"> + <gr-autogrow-textarea + class="text_area" + placeholder="Enter Message" + autocomplete="off" + .value="${this.message}" + @input="${this.handleMessageInput}" + ></gr-autogrow-textarea> + </span> + </section> + <section> + <span class="title">Preview:</span> + <span class="value"> + <div id="messagePreview" readonly></div> + </span> + </section> + </div> + </gr-dialog> + </dialog> + `; + } + + override updated(changedProperties: Map<string, any>) { + if (changedProperties.has('message') && this.messagePreview) { + this.messagePreview.innerHTML = this.message; + } + } + + override firstUpdated() { + // Set initial preview content if message exists + if (this.messagePreview && this.message) { + this.messagePreview.innerHTML = this.message; + } + } + + private openDialog() { + this.dialogOverlay?.showModal(); + this.dialog?.classList.toggle('invisible', false); + } + + private closeDialog() { + this.dialog?.classList.toggle('invisible', true); + this.dialogOverlay?.close(); + } + + private handleMessageInput(e: CustomEvent) { + this.message = e.detail.value; + } + + private handleExpireValueInput(e: Event) { + const target = e.target as HTMLInputElement; + this.expireAfterValue = target.value || ''; + } + + private handleExpireUnitChange(e: Event) { + const target = e.target as HTMLSelectElement; + this.expireAfterUnit = (target.value as ExpireUnit) || 'd'; + } + + private async saveMessage() { + const payload: { message: string; expires_at?: string } = { + message: this.message, + }; + + if (this.expireAfterValue) { + payload.expires_at = this.convertToFormattedDate( + this.expireAfterValue, + this.expireAfterUnit + ); + } + + try { + await this.plugin + .restApi() + .post('/config/server/messageoftheday~message', payload); + location.reload(); + } catch (error) { + console.error('Error saving message:', error); + } + } + + private convertToFormattedDate(value: string, unit: ExpireUnit): string { + const numValue = parseInt(value, 10); + let msToAdd = 0; + + switch (unit) { + case 'm': + msToAdd = numValue * 60 * 1000; + break; + case 'h': + msToAdd = numValue * 60 * 60 * 1000; + break; + case 'd': + msToAdd = numValue * 24 * 60 * 60 * 1000; + break; + case 'w': + msToAdd = numValue * 7 * 24 * 60 * 60 * 1000; + break; + } + + const now = new Date(); + const future = new Date(now.getTime() + msToAdd); + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }; + return new Intl.DateTimeFormat('en-US', options).format(future); + } +}
diff --git a/web/gr-messageoftheday-edit_test.ts b/web/gr-messageoftheday-edit_test.ts new file mode 100644 index 0000000..48ba140 --- /dev/null +++ b/web/gr-messageoftheday-edit_test.ts
@@ -0,0 +1,131 @@ +/** + * @license + * Copyright (C) 2026 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 './test/test-setup'; +import './gr-messageoftheday-edit'; +import {GrMessageOfTheDayEdit} from './gr-messageoftheday-edit'; +import {PluginApi} from '@gerritcodereview/typescript-api/plugin'; +import {assert} from '@open-wc/testing'; + +suite('gr-messageoftheday-edit tests', () => { + let element: GrMessageOfTheDayEdit; + let mockCapabilities: any; + let mockMessage: any; + + function queryInShadow<T extends HTMLElement>( + el: HTMLElement, + selector: string + ): T | null { + return el.shadowRoot?.querySelector<T>(selector) || null; + } + + async function createElement(): Promise<GrMessageOfTheDayEdit> { + const el = document.createElement( + 'gr-messageoftheday-edit' + ) as GrMessageOfTheDayEdit; + + // Set properties BEFORE adding to DOM + el.plugin = { + restApi: () => { + return { + get: async (url: string) => { + if (url.includes('capabilities')) { + return mockCapabilities; + } + return mockMessage; + }, + post: async () => ({}), + }; + }, + } as unknown as PluginApi; + + // Add to DOM + document.body.appendChild(el); + + await el.updateComplete; + // Wait for async capability check and message fetch + await new Promise(resolve => setTimeout(resolve, 100)); + await el.updateComplete; + return el; + } + + teardown(() => { + if (element && element.parentNode) { + element.parentNode.removeChild(element); + } + }); + + test('does not render when user lacks capability', async () => { + mockCapabilities = {}; + mockMessage = {}; + + element = await createElement(); + + const button = queryInShadow(element, '.icon-button'); + assert.isNull(button, 'button should not exist without capability'); + }); + + test('renders button when user has capability', async () => { + mockCapabilities = {'messageoftheday-updateBanner': true}; + mockMessage = {html: 'Existing message'}; + + element = await createElement(); + + const button = queryInShadow(element, '.icon-button'); + assert.isNotNull(button, 'button should exist with capability'); + }); + + test('preview shows HTML content when message is edited', async () => { + mockCapabilities = {'messageoftheday-updateBanner': true}; + mockMessage = {html: '<b>Bold text</b>'}; + + element = await createElement(); + + // Open dialog + const button = queryInShadow<HTMLElement>(element, '.icon-button'); + button!.click(); + await element.updateComplete; + + const preview = queryInShadow<HTMLDivElement>(element, '#messagePreview'); + assert.isNotNull(preview, 'preview should exist'); + + // Check that initial HTML is rendered + assert.include(preview!.innerHTML, '<b>Bold text</b>'); + + // Verify HTML is actually rendered as DOM + const bold = preview!.querySelector('b'); + assert.isNotNull(bold, 'HTML should be rendered as DOM elements'); + assert.equal(bold!.textContent, 'Bold text'); + + // Simulate typing in textarea + const textarea = queryInShadow(element, 'gr-autogrow-textarea'); + assert.isNotNull(textarea, 'textarea should exist'); + + // Simulate input event (like user typing) + textarea!.dispatchEvent(new CustomEvent('input', { + detail: {value: '<p>New <em>message</em></p>'}, + bubbles: true, + composed: true + })); + await element.updateComplete; + + // Check preview updates + assert.include(preview!.innerHTML, '<em>message</em>'); + const em = preview!.querySelector('em'); + assert.isNotNull(em, 'Updated HTML should render'); + assert.equal(em!.textContent, 'message'); + }); +});
diff --git a/gr-messageoftheday/plugin.js b/web/plugin.ts similarity index 76% rename from gr-messageoftheday/plugin.js rename to web/plugin.ts index 475e28d..635e850 100644 --- a/gr-messageoftheday/plugin.js +++ b/web/plugin.ts
@@ -1,6 +1,6 @@ /** * @license - * Copyright (C) 2021 The Android Open Source Project + * Copyright (C) 2026 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. @@ -14,11 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import '@gerritcodereview/typescript-api/gerrit'; +import './gr-messageoftheday-banner'; +import './gr-messageoftheday-edit'; -import './gr-messageoftheday-banner.js'; -import './gr-messageoftheday-edit.js'; - -Gerrit.install(plugin => { +window.Gerrit?.install(plugin => { plugin.registerCustomComponent('header-top-right', 'gr-messageoftheday-edit'); plugin.registerCustomComponent('banner', 'gr-messageoftheday-banner'); });
diff --git a/gr-messageoftheday/plugin.js b/web/test/test-setup.ts similarity index 60% copy from gr-messageoftheday/plugin.js copy to web/test/test-setup.ts index 475e28d..788b977 100644 --- a/gr-messageoftheday/plugin.js +++ b/web/test/test-setup.ts
@@ -1,6 +1,6 @@ /** * @license - * Copyright (C) 2021 The Android Open Source Project + * Copyright (C) 2026 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. @@ -14,11 +14,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import '@gerritcodereview/typescript-api/gerrit'; +import {css} from 'lit'; -import './gr-messageoftheday-banner.js'; -import './gr-messageoftheday-edit.js'; - -Gerrit.install(plugin => { - plugin.registerCustomComponent('header-top-right', 'gr-messageoftheday-edit'); - plugin.registerCustomComponent('banner', 'gr-messageoftheday-banner'); -}); +// Setup window.Gerrit mock +window.Gerrit = { + install: () => {}, + styles: { + font: css``, + form: css``, + material: css``, + menuPage: css``, + spinner: css``, + subPage: css``, + table: css``, + modal: css``, + }, +};
diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..65940e1 --- /dev/null +++ b/web/tsconfig.json
@@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig-plugins-base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": [ + "**/*.ts" + ] +}
diff --git a/web/web_test_runner.sh b/web/web_test_runner.sh new file mode 100755 index 0000000..e1a3bfb --- /dev/null +++ b/web/web_test_runner.sh
@@ -0,0 +1,11 @@ +#!/bin/bash + +set -euo pipefail +# Get the absolute path to the runfiles directory +RUNFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +./$1 --config $2 \ + --root-dir "${RUNFILES_DIR}" \ + --dir 'plugins/messageoftheday/web/_bazel_ts_out_tests' \ + --test-files 'plugins/messageoftheday/web/_bazel_ts_out_tests/*_test.js' \ + --ts-config="plugins/messageoftheday/web/tsconfig.json"