Skip to content

Commit cc190db

Browse files
committed
Add subscribe query parameter to QR code link
1 parent 9c3b6f1 commit cc190db

8 files changed

Lines changed: 192 additions & 19 deletions

e2e/tests/overview-page.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2023 Google LLC
2+
* Copyright 2023 Google LLC.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.

frontend/src/static/js/components/test/webstatus-subscribe-button.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ describe('webstatus-subscribe-button', () => {
8282
expect(dialog?.open).to.be.true;
8383
});
8484

85+
it('opens dialog automatically when autoOpen is true and user is logged in', async () => {
86+
const el = await fixture<SubscribeButton>(html`
87+
<webstatus-subscribe-button
88+
.userContext=${mockUserContext}
89+
.savedSearchId=${'test-id'}
90+
.autoOpen=${true}
91+
></webstatus-subscribe-button>
92+
`);
93+
const dialog = el.shadowRoot?.querySelector<ManageSubscriptionsDialog>(
94+
'webstatus-manage-subscriptions-dialog',
95+
);
96+
expect(dialog).to.exist;
97+
expect(dialog?.open).to.be.true;
98+
});
99+
85100
it('calls toaster on successful save', async () => {
86101
const toasterSpy = sinon.spy();
87102
const el = await fixture<SubscribeButton>(html`
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 {LitElement, TemplateResult, css, html} from 'lit';
18+
import {customElement, property, state} from 'lit/decorators.js';
19+
import {consume} from '@lit/context';
20+
import '@shoelace-style/shoelace/dist/components/dialog/dialog.js';
21+
import '@shoelace-style/shoelace/dist/components/button/button.js';
22+
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
23+
import {firebaseAuthContext, AuthConfig} from '../contexts/firebase-auth-context.js';
24+
import {toast} from '../utils/toast.js';
25+
import {THEME} from '../css/_theme-css.js';
26+
27+
@customElement('webstatus-login-prompt-dialog')
28+
export class WebstatusLoginPromptDialog extends LitElement {
29+
@property({type: Boolean})
30+
open = false;
31+
32+
@property({type: String})
33+
savedSearchName = '';
34+
35+
@consume({context: firebaseAuthContext, subscribe: true})
36+
@state()
37+
firebaseAuthConfig?: AuthConfig;
38+
39+
static styles = [
40+
THEME,
41+
css`
42+
sl-dialog::part(title) {
43+
font-weight: bold;
44+
}
45+
.content {
46+
display: flex;
47+
flex-direction: column;
48+
gap: var(--content-padding);
49+
}
50+
.footer {
51+
display: flex;
52+
justify-content: flex-end;
53+
}
54+
`
55+
];
56+
57+
async _handleLogin() {
58+
if (this.firebaseAuthConfig) {
59+
try {
60+
await this.firebaseAuthConfig.signIn();
61+
this.dispatchEvent(new CustomEvent('login-success'));
62+
this.open = false;
63+
} catch (error: any) {
64+
await toast(
65+
`Failed to login: ${error.message ?? 'unknown'}`,
66+
'danger',
67+
'exclamation-triangle',
68+
);
69+
}
70+
}
71+
}
72+
73+
_handleClose() {
74+
this.open = false;
75+
this.dispatchEvent(new CustomEvent('prompt-close'));
76+
}
77+
78+
render(): TemplateResult {
79+
return html`
80+
<sl-dialog
81+
.open=${this.open}
82+
label="Log in to Subscribe"
83+
@sl-after-hide=${this._handleClose}
84+
>
85+
<div class="content">
86+
<p>
87+
You need an account to subscribe to <strong>${this.savedSearchName}</strong> and receive latest updates.
88+
</p>
89+
</div>
90+
<div slot="footer" class="footer">
91+
<sl-button variant="primary" @click=${this._handleLogin}>
92+
<sl-icon slot="prefix" name="github"></sl-icon>
93+
Log in
94+
</sl-button>
95+
</div>
96+
</sl-dialog>
97+
`;
98+
}
99+
}

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import './webstatus-overview-data-loader.js';
3030
import './webstatus-overview-filters.js';
3131
import './webstatus-overview-pagination.js';
3232
import './webstatus-subscribe-button.js';
33+
import './webstatus-login-prompt-dialog.js';
3334
import {SHARED_STYLES} from '../css/shared-css.js';
3435
import {TaskTracker} from '../utils/task-tracker.js';
3536
import {ApiError} from '../api/errors.js';
@@ -55,6 +56,7 @@ import {
5556
formatOverviewPageUrl,
5657
getEditSavedSearch,
5758
getOrigin,
59+
getSubscribeToSavedSearch,
5860
QueryStringOverrides,
5961
updatePageUrl,
6062
} from '../utils/urls.js';
@@ -244,7 +246,7 @@ export class WebstatusOverviewContent extends LitElement {
244246
_updatePageUrl: (
245247
pathname: string,
246248
location: {search: string},
247-
overrides: {edit_saved_search?: boolean},
249+
overrides: QueryStringOverrides,
248250
) => void = updatePageUrl;
249251
_formatOverviewPageUrl: (
250252
location?: {search: string},
@@ -266,6 +268,14 @@ export class WebstatusOverviewContent extends LitElement {
266268
);
267269
this._updatePageUrl('', this.location, {edit_saved_search: false});
268270
}
271+
272+
if (
273+
getSubscribeToSavedSearch(this.location) &&
274+
this.userContext &&
275+
this.savedSearch
276+
) {
277+
this._updatePageUrl('', this.location, {subscribe: false});
278+
}
269279
}
270280

271281
render(): TemplateResult {
@@ -279,6 +289,7 @@ export class WebstatusOverviewContent extends LitElement {
279289
? savedSearch
280290
: undefined;
281291
const config = this.subscribeButtonConfig;
292+
const shouldSubscribe = getSubscribeToSavedSearch(this.location);
282293

283294
return html` <div class="main">
284295
<div class="hbox halign-items-space-between header-line">
@@ -287,6 +298,7 @@ export class WebstatusOverviewContent extends LitElement {
287298
? html`<webstatus-subscribe-button
288299
.savedSearchId=${config.id}
289300
.searchTitle=${config.title}
301+
.autoOpen=${shouldSubscribe}
290302
></webstatus-subscribe-button>`
291303
: nothing}
292304
</div>
@@ -323,6 +335,11 @@ export class WebstatusOverviewContent extends LitElement {
323335
.userContext=${this.userContext!}
324336
.savedSearch=${userSavedSearch?.value}
325337
.location=${this.location}
326-
></webstatus-saved-search-editor>`;
338+
></webstatus-saved-search-editor>
339+
<webstatus-login-prompt-dialog
340+
.open=${getSubscribeToSavedSearch(this.location) && this.userContext === null}
341+
.savedSearchName=${config?.title || 'this search'}
342+
@prompt-close=${() => this._updatePageUrl('', this.location, {subscribe: false})}
343+
></webstatus-login-prompt-dialog>`;
327344
}
328345
}

frontend/src/static/js/components/webstatus-saved-search-share-dialog.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export class WebstatusSavedSearchShareDialog extends LitElement {
1818
@property({type: String})
1919
shareableUrl: string = '';
2020

21+
get effectiveUrl(): string {
22+
return `${this.shareableUrl}&subscribe=true`;
23+
}
24+
2125
static styles = [
2226
THEME,
2327
css`
@@ -60,7 +64,7 @@ export class WebstatusSavedSearchShareDialog extends LitElement {
6064
display: grid;
6165
grid-template-columns: 1fr auto;
6266
grid-template-rows: auto auto;
63-
gap: 0; /* Remove gap to reduce whitespace */
67+
gap: 0; /* Minimize gap between heading and input */
6468
width: 100%;
6569
background-color: var(--color-background);
6670
padding: var(--content-padding);
@@ -72,7 +76,7 @@ export class WebstatusSavedSearchShareDialog extends LitElement {
7276
grid-column: 1;
7377
grid-row: 1;
7478
margin: 0;
75-
line-height: 1; /* Use 1 instead of 0 to maintain alignment */
79+
line-height: 1; /* seems to help with alignment a little bit */
7680
}
7781
7882
.link-share-box sl-input {
@@ -98,6 +102,8 @@ export class WebstatusSavedSearchShareDialog extends LitElement {
98102
grid-column: 2;
99103
grid-row: 1 / span 2;
100104
align-self: stretch;
105+
margin-top: calc(-1 * var(--content-padding-quarter)); /* trying to reduce the whitespace here */
106+
margin-bottom: calc(-1 * var(--content-padding-quarter));
101107
}
102108
103109
@@ -144,7 +150,7 @@ export class WebstatusSavedSearchShareDialog extends LitElement {
144150

145151
async copyToClipboard() {
146152
try {
147-
await navigator.clipboard.writeText(this.shareableUrl);
153+
await navigator.clipboard.writeText(this.effectiveUrl);
148154
await new Toast().toast('Link copied to clipboard', 'success', 'info-circle');
149155
} catch (err) {
150156
console.error('Failed to copy: ', err);
@@ -159,7 +165,7 @@ export class WebstatusSavedSearchShareDialog extends LitElement {
159165
<div class="qr-code-box">
160166
<h3>Share via QR code</h3>
161167
<sl-qr-code
162-
value="${this.shareableUrl}"
168+
value="${this.effectiveUrl}"
163169
size="180"
164170
fill="white"
165171
background="black"

frontend/src/static/js/components/webstatus-subscribe-button.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import {LitElement, html, TemplateResult} from 'lit';
17+
import {LitElement, html, TemplateResult, PropertyValueMap} from 'lit';
1818
import {customElement, property, state} from 'lit/decorators.js';
1919
import {consume} from '@lit/context';
2020
import {
@@ -45,9 +45,23 @@ export class SubscribeButton extends LitElement {
4545
@property({attribute: false})
4646
toaster = toast;
4747

48+
@property({type: Boolean})
49+
autoOpen = false;
50+
4851
@state()
4952
private _isSubscriptionDialogOpen = false;
5053

54+
protected updated(changedProperties: PropertyValueMap<this>): void {
55+
super.updated(changedProperties);
56+
if (
57+
(changedProperties.has('autoOpen') || changedProperties.has('userContext')) &&
58+
this.autoOpen &&
59+
this.userContext
60+
) {
61+
this._isSubscriptionDialogOpen = true;
62+
}
63+
}
64+
5165
render(): TemplateResult {
5266
if (!this.userContext || !this.savedSearchId) {
5367
return html``;

frontend/src/static/js/css/_theme-css.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const THEME = css`
5252
--content-padding: 16px;
5353
--content-padding-half: 8px;
5454
--content-padding-quarter: 4px;
55-
--dialog-size-large: 450px;
55+
--dialog-size-large: 480px;
5656
5757
--border-radius: 8px;
5858
--logo-color: var(--default-color);

frontend/src/static/js/utils/urls.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,45 +19,59 @@ function getQueryParam(qs: string, paramName: string): string {
1919
return params.get(paramName) || '';
2020
}
2121

22-
export function getSearchQuery(location: {search: string}): string {
22+
export function getSearchQuery(location?: {search: string}): string {
23+
if (!location) return '';
2324
return getQueryParam(location.search, 'q');
2425
}
2526

26-
export function getColumnsSpec(location: {search: string}): string {
27+
export function getColumnsSpec(location?: {search: string}): string {
28+
if (!location) return '';
2729
return getQueryParam(location.search, 'columns');
2830
}
2931

30-
export function getColumnOptions(location: {search: string}): string {
32+
export function getColumnOptions(location?: {search: string}): string {
33+
if (!location) return '';
3134
return getQueryParam(location.search, 'column_options');
3235
}
3336

34-
export function getSortSpec(location: {search: string}): string {
37+
export function getSortSpec(location?: {search: string}): string {
38+
if (!location) return '';
3539
return getQueryParam(location.search, 'sort');
3640
}
3741

38-
export function getPaginationStart(location: {search: string}): number {
42+
export function getPaginationStart(location?: {search: string}): number {
43+
if (!location) return 0;
3944
return Number(getQueryParam(location.search, 'start'));
4045
}
4146

42-
export function getWPTMetricView(location: {search: string}): string {
47+
export function getWPTMetricView(location?: {search: string}): string {
48+
if (!location) return '';
4349
return getQueryParam(location.search, 'wpt_metric_view');
4450
}
4551

46-
export function getLegacySearchID(location: {search: string}): string {
52+
export function getLegacySearchID(location?: {search: string}): string {
53+
if (!location) return '';
4754
return getQueryParam(location.search, 'search_id');
4855
}
4956

50-
export function getEditSavedSearch(location: {search: string}): boolean {
57+
export function getEditSavedSearch(location?: {search: string}): boolean {
58+
if (!location) return false;
5159
return Boolean(getQueryParam(location.search, 'edit_saved_search'));
5260
}
5361

62+
export function getSubscribeToSavedSearch(location?: {search: string}): boolean {
63+
if (!location) return false;
64+
return Boolean(getQueryParam(location.search, 'subscribe'));
65+
}
66+
5467
export interface DateRange {
5568
start?: Date;
5669
end?: Date;
5770
}
5871

5972
// getDate is used to get the date range specified in the URL.
60-
export function getDateRange(location: {search: string}): DateRange {
73+
export function getDateRange(location?: {search: string}): DateRange {
74+
if (!location) return {};
6175
const start = getQueryParam(location.search, 'startDate');
6276
const end = getQueryParam(location.search, 'endDate');
6377

@@ -68,7 +82,8 @@ export function getDateRange(location: {search: string}): DateRange {
6882
}
6983

7084
export const DEFAULT_ITEMS_PER_PAGE = 25;
71-
export function getPageSize(location: {search: string}): number {
85+
export function getPageSize(location?: {search: string}): number {
86+
if (!location) return DEFAULT_ITEMS_PER_PAGE;
7287
const num = Number(
7388
getQueryParam(location.search, 'num') || DEFAULT_ITEMS_PER_PAGE,
7489
);
@@ -85,6 +100,7 @@ export type QueryStringOverrides = {
85100
dateRange?: DateRange;
86101
column_options?: string[];
87102
edit_saved_search?: boolean;
103+
subscribe?: boolean;
88104
};
89105

90106
/* Given the router location object, return a query string with
@@ -161,6 +177,12 @@ function getContextualQueryStringParams(
161177
searchParams.set('edit_saved_search', '' + editBookmark);
162178
}
163179

180+
const subscribe =
181+
'subscribe' in overrides ? overrides.subscribe : undefined;
182+
if (subscribe) {
183+
searchParams.set('subscribe', '' + subscribe);
184+
}
185+
164186
return searchParams.toString() ? '?' + searchParams.toString() : '';
165187
}
166188

0 commit comments

Comments
 (0)