Skip to content

Commit 2bff1b9

Browse files
committed
feat: Add share dialog with QR code and support auto-subscriptions
Introduces a new share dialog with QR code generation for saved searches. Also adds handling for a `subscribe=true` URL parameter that prompts users to log in if needed, or automatically opens the subscription dialog.
1 parent 407ed46 commit 2bff1b9

20 files changed

Lines changed: 716 additions & 43 deletions
622 Bytes
Loading
11 Bytes
Loading
9 Bytes
Loading

e2e/tests/saved-searches.spec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const saveButtonLocator = (page: Page) =>
7777
.getByTestId('saved-search-save-button')
7878
.getByRole('button', {name: 'Save'});
7979
const shareButtonLocator = (page: Page) =>
80-
controlsLocator(page).getByLabel('Copy', {exact: true});
80+
controlsLocator(page).getByLabel('Share', {exact: true});
8181
const bookmarkEmptyIconLocator = (page: Page) =>
8282
controlsLocator(page).getByRole('button', {name: 'Bookmark', exact: true});
8383
const bookmarkFilledIconLocator = (page: Page) =>
@@ -404,11 +404,18 @@ test.describe('Saved Searches on Overview Page', () => {
404404
// Click the share icon
405405
await shareButtonLocator(page).click();
406406

407+
// Wait for the share dialog to appear
408+
const dialog = page.getByRole('dialog', {name: 'Share bookmark'});
409+
await expect(dialog).toBeVisible();
410+
411+
// Click the "Copy link" button inside the dialog
412+
await dialog.getByTestId('copy-link-button').click();
413+
407414
// Verify clipboard content
408415
const clipboardText = await page.evaluate(() =>
409416
navigator.clipboard.readText(),
410417
);
411-
expectUrlsEqual(clipboardText, page.url());
418+
expectUrlsEqual(clipboardText, `${page.url()}&subscribe=true`);
412419
});
413420

414421
test('Edit dialog opens automatically with edit_saved_search=true URL parameter', async ({

e2e/tests/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ export async function loginAsUser(
202202
const popupPromise = page.waitForEvent('popup');
203203
await page.goto('http://localhost:5555/');
204204
await waitForSidebarLoaded(page);
205-
await page.getByText('Log in').click();
205+
await page.getByRole('banner').getByText('Log in').click();
206206
const popup = await popupPromise;
207207

208208
await popup.waitForLoadState();
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {fixture, html, expect, oneEvent} from '@open-wc/testing';
18+
import sinon from 'sinon';
19+
import {WebstatusLoginPromptDialog} from '../webstatus-login-prompt-dialog.js';
20+
import {
21+
AuthConfig,
22+
firebaseAuthContext,
23+
} from '../../contexts/firebase-auth-context.js';
24+
import {provide} from '@lit/context';
25+
import {LitElement} from 'lit';
26+
import {customElement, property} from 'lit/decorators.js';
27+
28+
import '../webstatus-login-prompt-dialog.js';
29+
30+
@customElement('test-auth-provider')
31+
class TestAuthProvider extends LitElement {
32+
@property({type: Object})
33+
@provide({context: firebaseAuthContext})
34+
authConfig!: AuthConfig;
35+
36+
render() {
37+
return html`<slot></slot>`;
38+
}
39+
}
40+
41+
describe('webstatus-login-prompt-dialog', () => {
42+
let mockAuthConfig: AuthConfig;
43+
let signInStub: sinon.SinonStub;
44+
45+
beforeEach(() => {
46+
signInStub = sinon.stub().resolves();
47+
mockAuthConfig = {
48+
auth: {} as any,
49+
provider: {} as any,
50+
icon: 'github',
51+
signIn: signInStub,
52+
};
53+
});
54+
55+
it('renders nothing when closed', async () => {
56+
const el = await fixture<WebstatusLoginPromptDialog>(html`
57+
<webstatus-login-prompt-dialog
58+
.open=${false}
59+
></webstatus-login-prompt-dialog>
60+
`);
61+
const dialog = el.shadowRoot?.querySelector('sl-dialog');
62+
expect(dialog?.open).to.be.false;
63+
});
64+
65+
it('renders dialog when open', async () => {
66+
const el = await fixture<WebstatusLoginPromptDialog>(html`
67+
<webstatus-login-prompt-dialog
68+
.open=${true}
69+
.savedSearchName=${'My Search'}
70+
></webstatus-login-prompt-dialog>
71+
`);
72+
const dialog = el.shadowRoot?.querySelector('sl-dialog');
73+
expect(dialog?.open).to.be.true;
74+
expect(el.shadowRoot?.textContent).to.contain('My Search');
75+
});
76+
77+
it('calls signIn on button click and dispatches login-success', async () => {
78+
const wrapper = await fixture<TestAuthProvider>(html`
79+
<test-auth-provider .authConfig=${mockAuthConfig}>
80+
<webstatus-login-prompt-dialog
81+
.open=${true}
82+
></webstatus-login-prompt-dialog>
83+
</test-auth-provider>
84+
`);
85+
const el = wrapper.querySelector<WebstatusLoginPromptDialog>(
86+
'webstatus-login-prompt-dialog',
87+
)!;
88+
const button = el.shadowRoot?.querySelector('sl-button[variant="primary"]');
89+
expect(button).to.exist;
90+
91+
const eventPromise = oneEvent(el, 'login-success');
92+
(button as HTMLElement).click();
93+
94+
await eventPromise;
95+
expect(signInStub.calledOnce).to.be.true;
96+
expect(el.open).to.be.false;
97+
});
98+
});

frontend/src/static/js/components/test/webstatus-overview-content.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import {WebstatusOverviewContent} from '../webstatus-overview-content.js';
1818
import '../webstatus-overview-content.js';
1919
import {expect, fixture, html} from '@open-wc/testing';
20+
import {WebstatusLoginPromptDialog} from '../webstatus-login-prompt-dialog.js';
2021

2122
import {
2223
savedSearchHelpers,
@@ -50,6 +51,7 @@ describe('WebstatusOverviewContent', () => {
5051
element = await fixture<WebstatusOverviewContent>(html`
5152
<webstatus-overview-content></webstatus-overview-content>
5253
`);
54+
element.location = {search: ''};
5355
element._getOrigin = () => 'http://localhost';
5456
sinon.stub(element, '_getEditSavedSearch').returns(false);
5557
sinon.stub(element, '_updatePageUrl');
@@ -280,6 +282,21 @@ describe('WebstatusOverviewContent', () => {
280282

281283
expect(openSpy).to.have.been.calledOnce;
282284
});
285+
it('automatically opens the login prompt when subscribe param is true and user is logged out', async () => {
286+
element.location = {search: '?subscribe=true'};
287+
element.userContext = null;
288+
element.savedSearch = mockUserSearch;
289+
290+
element.requestUpdate();
291+
await element.updateComplete;
292+
293+
const promptDialog =
294+
element.shadowRoot?.querySelector<WebstatusLoginPromptDialog>(
295+
'webstatus-login-prompt-dialog',
296+
);
297+
expect(promptDialog).to.exist;
298+
expect(promptDialog?.open).to.be.true;
299+
});
283300
});
284301

285302
describe('Events & Interactions', () => {

frontend/src/static/js/components/test/webstatus-saved-search-controls.test.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ describe('WebstatusSavedSearchControls', () => {
141141
});
142142

143143
it('does not render active search controls when no savedSearch is provided', () => {
144-
const shareButton = element.shadowRoot!.querySelector('sl-copy-button');
144+
const shareButton = element.shadowRoot!.querySelector(
145+
'sl-icon-button[name="share"]',
146+
);
145147
const bookmarkButton = element.shadowRoot!.querySelector(
146148
'sl-icon-button[name^="star"]',
147149
);
@@ -182,7 +184,9 @@ describe('WebstatusSavedSearchControls', () => {
182184
const saveButton = element.shadowRoot!.querySelector(
183185
'sl-icon-button[name="floppy"]',
184186
);
185-
const shareButton = element.shadowRoot!.querySelector('sl-copy-button');
187+
const shareButton = element.shadowRoot!.querySelector(
188+
'sl-icon-button[name="share"]',
189+
);
186190
const bookmarkButton = element.shadowRoot!.querySelector(
187191
'sl-icon-button[name="star"]',
188192
);
@@ -205,13 +209,33 @@ describe('WebstatusSavedSearchControls', () => {
205209
expect(deleteButton).to.not.exist;
206210
});
207211

208-
it('configures share button correctly', () => {
209-
const copyButton = element.shadowRoot!.querySelector('sl-copy-button');
210-
const expectedUrl = `http://localhost:8080/features?q=saved%3A${mockSavedSearchViewerNotBookmarked.id}`;
211-
expect(copyButton).to.have.attribute('value', expectedUrl);
212-
expect(formatOverviewPageUrlStub).to.have.been.calledWith(mockLocation, {
213-
q: `saved:${mockSavedSearchViewerNotBookmarked.id}`,
214-
});
212+
it('opens modal when share button is clicked and renders QR code', async () => {
213+
const shareButton = element.shadowRoot!.querySelector<SlIconButton>(
214+
'sl-icon-button[name="share"]',
215+
)!;
216+
shareButton.click();
217+
await element.updateComplete;
218+
219+
// Wait for dialog to appear in body.
220+
await waitUntil(
221+
() =>
222+
document.body.querySelector('webstatus-saved-search-share-dialog') !==
223+
null,
224+
);
225+
226+
const shareDialog = document.body.querySelector(
227+
'webstatus-saved-search-share-dialog',
228+
)!;
229+
expect(shareDialog).to.exist;
230+
231+
const dialog = shareDialog.shadowRoot!.querySelector('sl-dialog');
232+
expect(dialog).to.exist;
233+
234+
const qrCode = dialog!.querySelector('sl-qr-code');
235+
expect(qrCode).to.exist;
236+
237+
const copyButton = dialog!.querySelector('sl-button[variant="primary"]');
238+
expect(copyButton).to.exist;
215239
});
216240

217241
it('calls handleBookmarkSavedSearch to bookmark when bookmark button is clicked', async () => {
@@ -332,7 +356,9 @@ describe('WebstatusSavedSearchControls', () => {
332356
const saveButton = element.shadowRoot!.querySelector(
333357
'sl-icon-button[name="floppy"]',
334358
);
335-
const shareButton = element.shadowRoot!.querySelector('sl-copy-button');
359+
const shareButton = element.shadowRoot!.querySelector(
360+
'sl-icon-button[name="share"]',
361+
);
336362
const bookmarkButton = element.shadowRoot!.querySelector(
337363
'sl-icon-button[name="star"]',
338364
);
@@ -446,7 +472,9 @@ describe('WebstatusSavedSearchControls', () => {
446472
const saveButton = element.shadowRoot!.querySelector(
447473
'sl-icon-button[name="floppy"]',
448474
);
449-
const shareButton = element.shadowRoot!.querySelector('sl-copy-button');
475+
const shareButton = element.shadowRoot!.querySelector(
476+
'sl-icon-button[name="share"]',
477+
);
450478
const bookmarkButton = element.shadowRoot!.querySelector(
451479
'sl-icon-button[name="star"]',
452480
);
@@ -469,10 +497,13 @@ describe('WebstatusSavedSearchControls', () => {
469497
expect(deleteButton).to.exist;
470498
});
471499

472-
it('configures share button correctly for owner', () => {
473-
const copyButton = element.shadowRoot!.querySelector('sl-copy-button');
474-
const expectedUrl = `http://localhost:8080/features?q=saved%3A${mockSavedSearchOwner.id}`;
475-
expect(copyButton).to.have.attribute('value', expectedUrl);
500+
it('configures share button correctly for owner', async () => {
501+
const shareButton = element.shadowRoot!.querySelector<SlIconButton>(
502+
'sl-icon-button[name="share"]',
503+
)!;
504+
shareButton.click();
505+
await element.updateComplete;
506+
476507
expect(formatOverviewPageUrlStub).to.have.been.calledWith(mockLocation, {
477508
q: `saved:${mockSavedSearchOwner.id}`,
478509
});

0 commit comments

Comments
 (0)