diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png index f61663204..ee76113a3 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png index 437367629..88461b84a 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png differ diff --git a/e2e/tests/saved-searches.spec.ts b/e2e/tests/saved-searches.spec.ts index ffd3f6fd5..c323381be 100644 --- a/e2e/tests/saved-searches.spec.ts +++ b/e2e/tests/saved-searches.spec.ts @@ -77,7 +77,7 @@ const saveButtonLocator = (page: Page) => .getByTestId('saved-search-save-button') .getByRole('button', {name: 'Save'}); const shareButtonLocator = (page: Page) => - controlsLocator(page).getByLabel('Copy', {exact: true}); + controlsLocator(page).getByLabel('Share', {exact: true}); const bookmarkEmptyIconLocator = (page: Page) => controlsLocator(page).getByRole('button', {name: 'Bookmark', exact: true}); const bookmarkFilledIconLocator = (page: Page) => @@ -404,11 +404,19 @@ test.describe('Saved Searches on Overview Page', () => { // Click the share icon await shareButtonLocator(page).click(); + // Wait for the share dialog to appear + const dialog = page.getByRole('dialog', {name: 'Share bookmark'}); + await expect(dialog).toBeVisible(); + console.log('Dialog HTML:', await dialog.innerHTML()); + + // Click the "Copy link" button inside the dialog + await page.getByTestId('copy-link-button').click(); + // Verify clipboard content const clipboardText = await page.evaluate(() => navigator.clipboard.readText(), ); - expectUrlsEqual(clipboardText, page.url()); + expectUrlsEqual(clipboardText, `${page.url()}&subscribe=true`); }); test('Edit dialog opens automatically with edit_saved_search=true URL parameter', async ({ diff --git a/e2e/tests/utils.ts b/e2e/tests/utils.ts index a287b2f59..b4345f452 100644 --- a/e2e/tests/utils.ts +++ b/e2e/tests/utils.ts @@ -202,7 +202,7 @@ export async function loginAsUser( const popupPromise = page.waitForEvent('popup'); await page.goto('http://localhost:5555/'); await waitForSidebarLoaded(page); - await page.getByText('Log in').click(); + await page.getByRole('banner').getByText('Log in').click(); const popup = await popupPromise; await popup.waitForLoadState(); diff --git a/frontend/src/static/img/shoelace/assets/icons/link.svg b/frontend/src/static/img/shoelace/assets/icons/link.svg new file mode 100644 index 000000000..823e4cd69 --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/link.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/static/js/components/test/webstatus-login-prompt-dialog.test.ts b/frontend/src/static/js/components/test/webstatus-login-prompt-dialog.test.ts new file mode 100644 index 000000000..87aeb7847 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-login-prompt-dialog.test.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2026 Google LLC + * + * 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 {fixture, html, expect, oneEvent} from '@open-wc/testing'; +import sinon from 'sinon'; +import {WebstatusLoginPromptDialog} from '../webstatus-login-prompt-dialog.js'; +import { + AuthConfig, + firebaseAuthContext, +} from '../../contexts/firebase-auth-context.js'; +import {provide} from '@lit/context'; +import {LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +import '../webstatus-login-prompt-dialog.js'; + +@customElement('test-auth-provider') +class TestAuthProvider extends LitElement { + @property({type: Object}) + @provide({context: firebaseAuthContext}) + authConfig!: AuthConfig; + + render() { + return html``; + } +} + +describe('webstatus-login-prompt-dialog', () => { + let mockAuthConfig: AuthConfig; + let signInStub: sinon.SinonStub; + + beforeEach(() => { + signInStub = sinon.stub().resolves(); + mockAuthConfig = { + auth: {} as any, + provider: {} as any, + icon: 'github', + signIn: signInStub, + }; + }); + + it('renders nothing when closed', async () => { + const el = await fixture(html` + + `); + const dialog = el.shadowRoot?.querySelector('sl-dialog'); + expect(dialog?.open).to.be.false; + }); + + it('renders dialog when open', async () => { + const el = await fixture(html` + + `); + const dialog = el.shadowRoot?.querySelector('sl-dialog'); + expect(dialog?.open).to.be.true; + expect(el.shadowRoot?.textContent).to.contain('My Search'); + }); + + it('calls signIn on button click and dispatches login-success', async () => { + const wrapper = await fixture(html` + + + + `); + const el = wrapper.querySelector( + 'webstatus-login-prompt-dialog', + )!; + const button = el.shadowRoot?.querySelector('sl-button[variant="primary"]'); + expect(button).to.exist; + + const eventPromise = oneEvent(el, 'login-success'); + (button as HTMLElement).click(); + + await eventPromise; + expect(signInStub.calledOnce).to.be.true; + expect(el.open).to.be.false; + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-overview-content.test.ts b/frontend/src/static/js/components/test/webstatus-overview-content.test.ts index 34229500e..59662cdf0 100644 --- a/frontend/src/static/js/components/test/webstatus-overview-content.test.ts +++ b/frontend/src/static/js/components/test/webstatus-overview-content.test.ts @@ -17,6 +17,7 @@ import {WebstatusOverviewContent} from '../webstatus-overview-content.js'; import '../webstatus-overview-content.js'; import {expect, fixture, html} from '@open-wc/testing'; +import {WebstatusLoginPromptDialog} from '../webstatus-login-prompt-dialog.js'; import { savedSearchHelpers, @@ -50,6 +51,7 @@ describe('WebstatusOverviewContent', () => { element = await fixture(html` `); + element.location = {search: ''}; element._getOrigin = () => 'http://localhost'; sinon.stub(element, '_getEditSavedSearch').returns(false); sinon.stub(element, '_updatePageUrl'); @@ -280,6 +282,21 @@ describe('WebstatusOverviewContent', () => { expect(openSpy).to.have.been.calledOnce; }); + it('automatically opens the login prompt when subscribe param is true and user is logged out', async () => { + element.location = {search: '?subscribe=true'}; + element.userContext = null; + element.savedSearch = mockUserSearch; + + element.requestUpdate(); + await element.updateComplete; + + const promptDialog = + element.shadowRoot?.querySelector( + 'webstatus-login-prompt-dialog', + ); + expect(promptDialog).to.exist; + expect(promptDialog?.open).to.be.true; + }); }); describe('Events & Interactions', () => { diff --git a/frontend/src/static/js/components/test/webstatus-saved-search-controls.test.ts b/frontend/src/static/js/components/test/webstatus-saved-search-controls.test.ts index bbda696cf..08d675243 100644 --- a/frontend/src/static/js/components/test/webstatus-saved-search-controls.test.ts +++ b/frontend/src/static/js/components/test/webstatus-saved-search-controls.test.ts @@ -141,7 +141,9 @@ describe('WebstatusSavedSearchControls', () => { }); it('does not render active search controls when no savedSearch is provided', () => { - const shareButton = element.shadowRoot!.querySelector('sl-copy-button'); + const shareButton = element.shadowRoot!.querySelector( + 'sl-icon-button[name="share"]', + ); const bookmarkButton = element.shadowRoot!.querySelector( 'sl-icon-button[name^="star"]', ); @@ -182,7 +184,9 @@ describe('WebstatusSavedSearchControls', () => { const saveButton = element.shadowRoot!.querySelector( 'sl-icon-button[name="floppy"]', ); - const shareButton = element.shadowRoot!.querySelector('sl-copy-button'); + const shareButton = element.shadowRoot!.querySelector( + 'sl-icon-button[name="share"]', + ); const bookmarkButton = element.shadowRoot!.querySelector( 'sl-icon-button[name="star"]', ); @@ -205,13 +209,33 @@ describe('WebstatusSavedSearchControls', () => { expect(deleteButton).to.not.exist; }); - it('configures share button correctly', () => { - const copyButton = element.shadowRoot!.querySelector('sl-copy-button'); - const expectedUrl = `http://localhost:8080/features?q=saved%3A${mockSavedSearchViewerNotBookmarked.id}`; - expect(copyButton).to.have.attribute('value', expectedUrl); - expect(formatOverviewPageUrlStub).to.have.been.calledWith(mockLocation, { - q: `saved:${mockSavedSearchViewerNotBookmarked.id}`, - }); + it('opens modal when share button is clicked and renders QR code', async () => { + const shareButton = element.shadowRoot!.querySelector( + 'sl-icon-button[name="share"]', + )!; + shareButton.click(); + await element.updateComplete; + + // Wait for dialog to appear in body. + await waitUntil( + () => + document.body.querySelector('webstatus-saved-search-share-dialog') !== + null, + ); + + const shareDialog = document.body.querySelector( + 'webstatus-saved-search-share-dialog', + )!; + expect(shareDialog).to.exist; + + const dialog = shareDialog.shadowRoot!.querySelector('sl-dialog'); + expect(dialog).to.exist; + + const qrCode = dialog!.querySelector('sl-qr-code'); + expect(qrCode).to.exist; + + const copyButton = dialog!.querySelector('sl-button[variant="primary"]'); + expect(copyButton).to.exist; }); it('calls handleBookmarkSavedSearch to bookmark when bookmark button is clicked', async () => { @@ -332,7 +356,9 @@ describe('WebstatusSavedSearchControls', () => { const saveButton = element.shadowRoot!.querySelector( 'sl-icon-button[name="floppy"]', ); - const shareButton = element.shadowRoot!.querySelector('sl-copy-button'); + const shareButton = element.shadowRoot!.querySelector( + 'sl-icon-button[name="share"]', + ); const bookmarkButton = element.shadowRoot!.querySelector( 'sl-icon-button[name="star"]', ); @@ -446,7 +472,9 @@ describe('WebstatusSavedSearchControls', () => { const saveButton = element.shadowRoot!.querySelector( 'sl-icon-button[name="floppy"]', ); - const shareButton = element.shadowRoot!.querySelector('sl-copy-button'); + const shareButton = element.shadowRoot!.querySelector( + 'sl-icon-button[name="share"]', + ); const bookmarkButton = element.shadowRoot!.querySelector( 'sl-icon-button[name="star"]', ); @@ -469,10 +497,13 @@ describe('WebstatusSavedSearchControls', () => { expect(deleteButton).to.exist; }); - it('configures share button correctly for owner', () => { - const copyButton = element.shadowRoot!.querySelector('sl-copy-button'); - const expectedUrl = `http://localhost:8080/features?q=saved%3A${mockSavedSearchOwner.id}`; - expect(copyButton).to.have.attribute('value', expectedUrl); + it('configures share button correctly for owner', async () => { + const shareButton = element.shadowRoot!.querySelector( + 'sl-icon-button[name="share"]', + )!; + shareButton.click(); + await element.updateComplete; + expect(formatOverviewPageUrlStub).to.have.been.calledWith(mockLocation, { q: `saved:${mockSavedSearchOwner.id}`, }); diff --git a/frontend/src/static/js/components/test/webstatus-saved-search-share-dialog.test.ts b/frontend/src/static/js/components/test/webstatus-saved-search-share-dialog.test.ts new file mode 100644 index 000000000..1c90b85fd --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-saved-search-share-dialog.test.ts @@ -0,0 +1,117 @@ +/** + * Copyright 2026 Google LLC + * + * 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 {fixture, html, expect, waitUntil} from '@open-wc/testing'; +import sinon from 'sinon'; +import {WebstatusSavedSearchShareDialog} from '../webstatus-saved-search-share-dialog.js'; +import {UserSavedSearch} from '../../utils/constants.js'; +import {Toast} from '../../utils/toast.js'; + +import '../webstatus-saved-search-share-dialog.js'; + +describe('webstatus-saved-search-share-dialog', () => { + let el: WebstatusSavedSearchShareDialog; + let toastStub: sinon.SinonStub; + let clipboardStub: sinon.SinonStub; + + const mockSavedSearch: UserSavedSearch = { + id: 'test-id', + name: 'Test Search', + query: 'feature:css', + description: 'A test search', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + beforeEach(async () => { + toastStub = sinon.stub(Toast.prototype, 'toast').resolves(); + + if (!navigator.clipboard) { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: async () => {}, + }, + writable: true, + }); + } + clipboardStub = sinon.stub(navigator.clipboard, 'writeText').resolves(); + + el = await fixture(html` + + `); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('computes effectiveUrl correctly', async () => { + el.shareableUrl = 'http://localhost:8080/features?q=saved:test-id'; + await el.updateComplete; + expect(el.effectiveUrl).to.equal( + 'http://localhost:8080/features?q=saved%3Atest-id&subscribe=true', + ); + }); + + it('computes effectiveUrl correctly when fallback is needed', async () => { + el.shareableUrl = 'http://localhost:8080/features'; + await el.updateComplete; + expect(el.effectiveUrl).to.equal( + 'http://localhost:8080/features?subscribe=true', + ); + }); + + it('copies effectiveUrl to clipboard on button click', async () => { + el.shareableUrl = 'http://localhost:8080/features?q=saved:test-id'; + await el.updateComplete; + + await el.openWithContext(mockSavedSearch, el.shareableUrl); + await el.updateComplete; + + const dialog = el.shadowRoot?.querySelector('sl-dialog'); + expect(dialog).to.exist; + + const copyButton = dialog?.querySelector( + 'sl-button[variant="primary"]', + ); + expect(copyButton).to.exist; + + copyButton!.click(); + + await waitUntil(() => toastStub.calledOnce); + + expect( + clipboardStub.calledOnceWith( + 'http://localhost:8080/features?q=saved%3Atest-id&subscribe=true', + ), + ).to.be.true; + expect(toastStub.calledOnce).to.be.true; + }); + + it('renders QR code when open', async () => { + el.shareableUrl = 'http://localhost:8080/features?q=saved:test-id'; + await el.updateComplete; + + await el.openWithContext(mockSavedSearch, el.shareableUrl); + await el.updateComplete; + + const qrCode = el.shadowRoot?.querySelector('sl-qr-code'); + expect(qrCode).to.exist; + expect(qrCode?.getAttribute('value')).to.equal( + 'http://localhost:8080/features?q=saved%3Atest-id&subscribe=true', + ); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts b/frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts index 07ce4cbde..66fe61229 100644 --- a/frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts +++ b/frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts @@ -82,6 +82,21 @@ describe('webstatus-subscribe-button', () => { expect(dialog?.open).to.be.true; }); + it('opens dialog automatically when autoOpen is true and user is logged in', async () => { + const el = await fixture(html` + + `); + const dialog = el.shadowRoot?.querySelector( + 'webstatus-manage-subscriptions-dialog', + ); + expect(dialog).to.exist; + expect(dialog?.open).to.be.true; + }); + it('calls toaster on successful save', async () => { const toasterSpy = sinon.spy(); const el = await fixture(html` diff --git a/frontend/src/static/js/components/webstatus-login-prompt-dialog.ts b/frontend/src/static/js/components/webstatus-login-prompt-dialog.ts new file mode 100644 index 000000000..dd191eef0 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-login-prompt-dialog.ts @@ -0,0 +1,103 @@ +/** + * Copyright 2026 Google LLC + * + * 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 {LitElement, TemplateResult, css, html} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {consume} from '@lit/context'; +import { + firebaseAuthContext, + AuthConfig, +} from '../contexts/firebase-auth-context.js'; +import {toast} from '../utils/toast.js'; +import {SHARED_STYLES} from '../css/shared-css.js'; + +@customElement('webstatus-login-prompt-dialog') +export class WebstatusLoginPromptDialog extends LitElement { + @property({type: Boolean}) + open = false; + + @property({type: String}) + savedSearchName = ''; + + @consume({context: firebaseAuthContext, subscribe: true}) + @state() + firebaseAuthConfig?: AuthConfig; + + static styles = [ + SHARED_STYLES, + css` + .content { + display: flex; + flex-direction: column; + gap: var(--content-padding); + } + .footer { + display: flex; + justify-content: flex-end; + } + `, + ]; + + async _handleLogin() { + if (this.firebaseAuthConfig) { + try { + await this.firebaseAuthConfig.signIn(); + this.dispatchEvent( + new CustomEvent('login-success', {bubbles: true, composed: true}), + ); + this.open = false; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'unknown'; + await toast( + `Failed to login: ${errorMessage}`, + 'danger', + 'exclamation-triangle', + ); + } + } + } + + _handleClose() { + this.open = false; + this.dispatchEvent( + new CustomEvent('prompt-close', {bubbles: true, composed: true}), + ); + } + + render(): TemplateResult { + return html` + +
+

+ You need an account to subscribe to + ${this.savedSearchName} and receive the latest + updates. +

+
+ +
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-overview-content.ts b/frontend/src/static/js/components/webstatus-overview-content.ts index b8746e14c..efbac04d5 100644 --- a/frontend/src/static/js/components/webstatus-overview-content.ts +++ b/frontend/src/static/js/components/webstatus-overview-content.ts @@ -30,6 +30,7 @@ import './webstatus-overview-data-loader.js'; import './webstatus-overview-filters.js'; import './webstatus-overview-pagination.js'; import './webstatus-subscribe-button.js'; +import './webstatus-login-prompt-dialog.js'; import {SHARED_STYLES} from '../css/shared-css.js'; import {TaskTracker} from '../utils/task-tracker.js'; import {ApiError} from '../api/errors.js'; @@ -55,6 +56,7 @@ import { formatOverviewPageUrl, getEditSavedSearch, getOrigin, + getSubscribeToSavedSearch, QueryStringOverrides, updatePageUrl, } from '../utils/urls.js'; @@ -244,7 +246,7 @@ export class WebstatusOverviewContent extends LitElement { _updatePageUrl: ( pathname: string, location: {search: string}, - overrides: {edit_saved_search?: boolean}, + overrides: QueryStringOverrides, ) => void = updatePageUrl; _formatOverviewPageUrl: ( location?: {search: string}, @@ -255,6 +257,7 @@ export class WebstatusOverviewContent extends LitElement { _changedProperties: PropertyValueMap, ): Promise { if ( + this.location && this._getEditSavedSearch(this.location) && !this.savedSearchEditor.isOpen() && this.savedSearch @@ -266,6 +269,15 @@ export class WebstatusOverviewContent extends LitElement { ); this._updatePageUrl('', this.location, {edit_saved_search: false}); } + + if ( + this.location && + getSubscribeToSavedSearch(this.location) && + this.userContext && + this.savedSearch + ) { + this._updatePageUrl('', this.location, {subscribe: false}); + } } render(): TemplateResult { @@ -279,6 +291,9 @@ export class WebstatusOverviewContent extends LitElement { ? savedSearch : undefined; const config = this.subscribeButtonConfig; + const shouldSubscribe = this.location + ? getSubscribeToSavedSearch(this.location) + : false; return html`
@@ -287,6 +302,7 @@ export class WebstatusOverviewContent extends LitElement { ? html`` : nothing}
@@ -323,6 +339,14 @@ export class WebstatusOverviewContent extends LitElement { .userContext=${this.userContext!} .savedSearch=${userSavedSearch?.value} .location=${this.location} - >`; + > + + this._updatePageUrl('', this.location, {subscribe: false})} + >`; } } diff --git a/frontend/src/static/js/components/webstatus-overview-data-loader.ts b/frontend/src/static/js/components/webstatus-overview-data-loader.ts index 035a3e402..9076e6c41 100644 --- a/frontend/src/static/js/components/webstatus-overview-data-loader.ts +++ b/frontend/src/static/js/components/webstatus-overview-data-loader.ts @@ -54,11 +54,9 @@ export class WebstatusOverviewDataLoader extends LitElement { savedSearch: CurrentSavedSearch; render(): TemplateResult { - const columns: ColumnKey[] = parseColumnsSpec( - getColumnsSpec(this.location), - ); const location = this.location; if (!location) return html``; + const columns: ColumnKey[] = parseColumnsSpec(getColumnsSpec(location)); const sortSpec = getSortSpec(location) || DEFAULT_SORT_SPEC; const groupCells = renderGroupCells(location, columns, sortSpec!); let headerCells: TemplateResult[] = []; diff --git a/frontend/src/static/js/components/webstatus-overview-pagination.ts b/frontend/src/static/js/components/webstatus-overview-pagination.ts index 3b75f58d7..6515c1a78 100644 --- a/frontend/src/static/js/components/webstatus-overview-pagination.ts +++ b/frontend/src/static/js/components/webstatus-overview-pagination.ts @@ -208,8 +208,10 @@ export class WebstatusOverviewPagination extends LitElement { return html``; } - this.start = getPaginationStart(this.location); - this.pageSize = getPageSize(this.location); + this.start = this.location ? getPaginationStart(this.location) : 0; + this.pageSize = this.location + ? getPageSize(this.location) + : DEFAULT_ITEMS_PER_PAGE; const prevUrl = this.formatUrlForRelativeOffset(-this.pageSize); const nextUrl = this.formatUrlForRelativeOffset(this.pageSize); diff --git a/frontend/src/static/js/components/webstatus-saved-search-controls.ts b/frontend/src/static/js/components/webstatus-saved-search-controls.ts index c17f968f4..856349461 100644 --- a/frontend/src/static/js/components/webstatus-saved-search-controls.ts +++ b/frontend/src/static/js/components/webstatus-saved-search-controls.ts @@ -16,6 +16,7 @@ import {LitElement, TemplateResult, css, html, nothing} from 'lit'; import {customElement, property, query, state} from 'lit/decorators.js'; +import {openShareDialog} from './webstatus-saved-search-share-dialog.js'; import {APIClient} from '../contexts/api-client-context.js'; import {UserContext} from '../contexts/firebase-user-context.js'; import { @@ -207,24 +208,15 @@ export class WebstatusSavedSearchControls extends LitElement { savedSearch: UserSavedSearch, ): TemplateResult { const isOwner = savedSearch.permissions?.role === BookmarkOwnerRole; - const shareableUrl = `${this._getOrigin()}${this._formatOverviewPageUrl(this.location, {q: `saved:${savedSearch.id}`})}`; return html` - - + { + const shareableUrl = `${this._getOrigin()}${this._formatOverviewPageUrl(this.location, {q: `saved:${savedSearch.id}`})}`; + void openShareDialog(savedSearch, shareableUrl); + }} + > ${this.renderBookmarkControl(savedSearch, isOwner)} ${isOwner ? html` diff --git a/frontend/src/static/js/components/webstatus-saved-search-share-dialog.ts b/frontend/src/static/js/components/webstatus-saved-search-share-dialog.ts new file mode 100644 index 000000000..8e02c6873 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-saved-search-share-dialog.ts @@ -0,0 +1,239 @@ +/** + * Copyright 2026 Google LLC + * + * 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 {LitElement, TemplateResult, css, html} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {UserSavedSearch} from '../utils/constants.js'; +import {Toast} from '../utils/toast.js'; +import {SHARED_STYLES} from '../css/shared-css.js'; + +@customElement('webstatus-saved-search-share-dialog') +export class WebstatusSavedSearchShareDialog extends LitElement { + @property({type: Object}) + savedSearch?: UserSavedSearch; + + @property({type: String}) + shareableUrl: string = ''; + + get effectiveUrl(): string { + try { + const url = new URL(this.shareableUrl); + url.searchParams.set('subscribe', 'true'); + return url.toString(); + } catch { + // Fallback if shareableUrl is relative or invalid. + const separator = this.shareableUrl.includes('?') ? '&' : '?'; + return `${this.shareableUrl}${separator}subscribe=true`; + } + } + + static styles = [ + SHARED_STYLES, + css` + sl-dialog::part(body) { + padding-top: 0; + } + + .qr-code-box { + border: 1px solid var(--border-color, #ccc); + border-radius: var(--border-radius, 4px); + padding: var(--content-padding-half) var(--content-padding) + var(--content-padding-quarter) var(--content-padding); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--content-padding); + margin: 0 auto; + margin-bottom: var(--content-padding-quarter); + width: 25em; + } + + .qr-code-box h3 { + margin: 0.5em; + font-weight: bold; + font-size: 1rem; + } + + .qr-code-box sl-button::part(base) { + padding-bottom: var(--content-padding-quarter); + font-weight: normal; + } + + .link-share-box { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 0; + width: 100%; + border-radius: var(--border-radius); + align-items: stretch; + margin-top: 1em; + } + + .link-share-box h3 { + grid-column: 1; + grid-row: 1; + margin: 0; + font-size: 1rem; + font-weight: bold; + } + + .link-share-box sl-input { + grid-column: 1; + grid-row: 2; + } + + .link-share-box sl-input::part(base) { + border: none; + background-color: transparent; + box-shadow: none; + } + + .link-share-box sl-input::part(input) { + padding-left: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-size: 0.875rem; + } + + .link-share-box sl-button { + grid-column: 2; + grid-row: 1 / span 2; + align-self: stretch; + } + `, + ]; + + async openWithContext(savedSearch: UserSavedSearch, shareableUrl: string) { + this.savedSearch = savedSearch; + this.shareableUrl = shareableUrl; + const dialog = this.shadowRoot?.querySelector('sl-dialog'); + if (dialog?.show) await dialog.show(); + } + + async hide() { + const dialog = this.shadowRoot?.querySelector('sl-dialog'); + if (dialog?.hide) await dialog.hide(); + } + + async drawLogoOnCanvas(qrCodeElement: Element) { + if (!qrCodeElement.shadowRoot) return; + const canvas = qrCodeElement.shadowRoot.querySelector('canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const logo = new Image(); + logo.src = '/public/img/cross.svg'; + await logo.decode(); + + const logoSize = canvas.width * 0.2; + const x = (canvas.width - logoSize) / 2; + const y = (canvas.height - logoSize) / 2; + + ctx.drawImage(logo, x, y, logoSize, logoSize); + } + + saveQRCode() { + const canvas = this.shadowRoot + ?.querySelector('sl-qr-code') + ?.shadowRoot?.querySelector('canvas'); + if (!canvas) return; + const dataURL = canvas.toDataURL('image/png'); + const a = document.createElement('a'); + a.href = dataURL; + a.download = `qr-code-${this.savedSearch?.id}.png`; + a.click(); + } + + async copyToClipboard() { + try { + await navigator.clipboard.writeText(this.effectiveUrl); + await new Toast().toast( + 'Link copied to clipboard', + 'success', + 'info-circle', + ); + } catch (err) { + console.error('Failed to copy: ', err); + await new Toast().toast( + 'Failed to copy link', + 'danger', + 'exclamation-triangle', + ); + } + } + + render(): TemplateResult { + return html` + +
+
+

Share via QR code

+ { + if (e.target instanceof Element) { + void this.drawLogoOnCanvas(e.target); + } + }} + > + + Save QR code + +
+ + +
+
+ `; + } +} + +let shareDialogEl: WebstatusSavedSearchShareDialog | null = null; + +export async function openShareDialog( + savedSearch: UserSavedSearch, + shareableUrl: string, +): Promise { + if (!shareDialogEl) { + shareDialogEl = new WebstatusSavedSearchShareDialog(); + document.body.appendChild(shareDialogEl); + await shareDialogEl.updateComplete; + } + await shareDialogEl.openWithContext(savedSearch, shareableUrl); + return shareDialogEl; +} diff --git a/frontend/src/static/js/components/webstatus-subscribe-button.ts b/frontend/src/static/js/components/webstatus-subscribe-button.ts index a80b00c90..7e0286746 100644 --- a/frontend/src/static/js/components/webstatus-subscribe-button.ts +++ b/frontend/src/static/js/components/webstatus-subscribe-button.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import {LitElement, html, TemplateResult} from 'lit'; +import {LitElement, html, TemplateResult, PropertyValueMap} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; import {consume} from '@lit/context'; import { @@ -45,9 +45,24 @@ export class SubscribeButton extends LitElement { @property({attribute: false}) toaster = toast; + @property({type: Boolean}) + autoOpen = false; + @state() private _isSubscriptionDialogOpen = false; + protected updated(changedProperties: PropertyValueMap): void { + super.updated(changedProperties); + if ( + (changedProperties.has('autoOpen') || + changedProperties.has('userContext')) && + this.autoOpen && + this.userContext + ) { + this._isSubscriptionDialogOpen = true; + } + } + render(): TemplateResult { if (!this.userContext || !this.savedSearchId) { return html``; diff --git a/frontend/src/static/js/index.ts b/frontend/src/static/js/index.ts index 6ec2910f5..ecd329396 100644 --- a/frontend/src/static/js/index.ts +++ b/frontend/src/static/js/index.ts @@ -29,6 +29,7 @@ import '@shoelace-style/shoelace/dist/components/input/input.js'; import '@shoelace-style/shoelace/dist/components/menu/menu.js'; import '@shoelace-style/shoelace/dist/components/menu-item/menu-item.js'; import '@shoelace-style/shoelace/dist/components/option/option.js'; +import '@shoelace-style/shoelace/dist/components/qr-code/qr-code.js'; import '@shoelace-style/shoelace/dist/components/radio-button/radio-button.js'; import '@shoelace-style/shoelace/dist/components/radio-group/radio-group.js'; import '@shoelace-style/shoelace/dist/components/select/select.js'; diff --git a/frontend/src/static/js/utils/urls.ts b/frontend/src/static/js/utils/urls.ts index 1429be07b..e9cf09c67 100644 --- a/frontend/src/static/js/utils/urls.ts +++ b/frontend/src/static/js/utils/urls.ts @@ -51,6 +51,10 @@ export function getEditSavedSearch(location: {search: string}): boolean { return Boolean(getQueryParam(location.search, 'edit_saved_search')); } +export function getSubscribeToSavedSearch(location: {search: string}): boolean { + return Boolean(getQueryParam(location.search, 'subscribe')); +} + export interface DateRange { start?: Date; end?: Date; @@ -85,6 +89,7 @@ export type QueryStringOverrides = { dateRange?: DateRange; column_options?: string[]; edit_saved_search?: boolean; + subscribe?: boolean; }; /* Given the router location object, return a query string with @@ -161,6 +166,11 @@ function getContextualQueryStringParams( searchParams.set('edit_saved_search', '' + editBookmark); } + const subscribe = 'subscribe' in overrides ? overrides.subscribe : undefined; + if (subscribe) { + searchParams.set('subscribe', '' + subscribe); + } + return searchParams.toString() ? '?' + searchParams.toString() : ''; }