Skip to content

Commit ebfb4d7

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 ebfb4d7

17 files changed

Lines changed: 792 additions & 38 deletions
11 Bytes
Loading
9 Bytes
Loading

e2e/tests/saved-searches.spec.ts

Lines changed: 10 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,19 @@ 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 shareDialog = page.locator('webstatus-saved-search-share-dialog');
409+
const dialog = shareDialog.locator('sl-dialog');
410+
await expect(dialog).toBeVisible();
411+
412+
// Click the "Copy link" button inside the dialog
413+
await dialog.locator('sl-button[variant="primary"]').click();
414+
407415
// Verify clipboard content
408416
const clipboardText = await page.evaluate(() =>
409417
navigator.clipboard.readText(),
410418
);
411-
expectUrlsEqual(clipboardText, page.url());
419+
expectUrlsEqual(clipboardText, `${page.url()}&subscribe=true`);
412420
});
413421

414422
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,20 @@ describe('WebstatusOverviewContent', () => {
280280

281281
expect(openSpy).to.have.been.calledOnce;
282282
});
283+
it('automatically opens the login prompt when subscribe param is true and user is logged out', async () => {
284+
element.location = {search: '?subscribe=true'};
285+
element.userContext = null;
286+
element.savedSearch = mockUserSearch;
287+
288+
element.requestUpdate();
289+
await element.updateComplete;
290+
291+
const promptDialog = element.shadowRoot?.querySelector(
292+
'webstatus-login-prompt-dialog',
293+
);
294+
expect(promptDialog).to.exist;
295+
expect((promptDialog as any).open).to.be.true;
296+
});
283297
});
284298

285299
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
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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} from '@open-wc/testing';
18+
import sinon from 'sinon';
19+
import {WebstatusSavedSearchShareDialog} from '../webstatus-saved-search-share-dialog.js';
20+
import {UserSavedSearch} from '../../utils/constants.js';
21+
import {Toast} from '../../utils/toast.js';
22+
23+
import '../webstatus-saved-search-share-dialog.js';
24+
25+
describe('webstatus-saved-search-share-dialog', () => {
26+
let el: WebstatusSavedSearchShareDialog;
27+
let toastStub: sinon.SinonStub;
28+
let clipboardStub: sinon.SinonStub;
29+
30+
const mockSavedSearch: UserSavedSearch = {
31+
id: 'test-id',
32+
name: 'Test Search',
33+
query: 'feature:css',
34+
description: 'A test search',
35+
created_at: new Date().toISOString(),
36+
updated_at: new Date().toISOString(),
37+
};
38+
39+
beforeEach(async () => {
40+
toastStub = sinon.stub(Toast.prototype, 'toast').resolves();
41+
42+
if (!navigator.clipboard) {
43+
Object.defineProperty(navigator, 'clipboard', {
44+
value: {
45+
writeText: async () => {},
46+
},
47+
writable: true,
48+
});
49+
}
50+
clipboardStub = sinon.stub(navigator.clipboard, 'writeText').resolves();
51+
52+
el = await fixture<WebstatusSavedSearchShareDialog>(html`
53+
<webstatus-saved-search-share-dialog></webstatus-saved-search-share-dialog>
54+
`);
55+
});
56+
57+
afterEach(() => {
58+
sinon.restore();
59+
});
60+
61+
it('computes effectiveUrl correctly', async () => {
62+
el.shareableUrl = 'http://localhost:8080/features?q=saved:test-id';
63+
await el.updateComplete;
64+
expect(el.effectiveUrl).to.equal(
65+
'http://localhost:8080/features?q=saved:test-id&subscribe=true',
66+
);
67+
});
68+
69+
it('computes effectiveUrl correctly when fallback is needed', async () => {
70+
el.shareableUrl = 'http://localhost:8080/features';
71+
await el.updateComplete;
72+
expect(el.effectiveUrl).to.equal(
73+
'http://localhost:8080/features?subscribe=true',
74+
);
75+
});
76+
77+
it('copies effectiveUrl to clipboard on button click', async () => {
78+
el.shareableUrl = 'http://localhost:8080/features?q=saved:test-id';
79+
await el.updateComplete;
80+
81+
await el.openWithContext(mockSavedSearch, el.shareableUrl);
82+
await el.updateComplete;
83+
84+
const dialog = el.shadowRoot?.querySelector('sl-dialog');
85+
expect(dialog).to.exist;
86+
87+
const copyButton = dialog?.querySelector<HTMLElement>(
88+
'sl-button[variant="primary"]',
89+
);
90+
expect(copyButton).to.exist;
91+
92+
copyButton!.click();
93+
94+
expect(
95+
clipboardStub.calledOnceWith(
96+
'http://localhost:8080/features?q=saved:test-id&subscribe=true',
97+
),
98+
).to.be.true;
99+
expect(toastStub.calledOnce).to.be.true;
100+
});
101+
102+
it('renders QR code when open', async () => {
103+
el.shareableUrl = 'http://localhost:8080/features?q=saved:test-id';
104+
await el.updateComplete;
105+
106+
await el.openWithContext(mockSavedSearch, el.shareableUrl);
107+
await el.updateComplete;
108+
109+
const qrCode = el.shadowRoot?.querySelector('sl-qr-code');
110+
expect(qrCode).to.exist;
111+
expect(qrCode?.getAttribute('value')).to.equal(
112+
'http://localhost:8080/features?q=saved:test-id&subscribe=true',
113+
);
114+
});
115+
});

0 commit comments

Comments
 (0)