diff --git a/e2e/tests/notification-channels.spec.ts b/e2e/tests/notification-channels.spec.ts new file mode 100644 index 000000000..d45d5113f --- /dev/null +++ b/e2e/tests/notification-channels.spec.ts @@ -0,0 +1,70 @@ +/** + * 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 {test, expect} from '@playwright/test'; +import {loginAsUser, BASE_URL} from './utils'; + +test.describe('Notification Channels Page', () => { + test('redirects unauthenticated user to home and shows toast', async ({ + page, + }) => { + await page.goto(`${BASE_URL}/settings/notification-channels`); + + // Expect to be redirected to the home page. + await expect(page).toHaveURL(BASE_URL); + // FYI: We do not assert the toast because it flashes on the screen due to the redirect. + }); + + test('authenticated user sees their email channel and coming soon messages', async ({ + page, + }) => { + // Log in as a test user + await loginAsUser(page, 'test user 1'); + + // Navigate to the notification channels page + await page.goto(`${BASE_URL}/settings/notification-channels`); + + // Move the mouse to a neutral position to avoid hover effects on the screenshot + await page.mouse.move(0, 0); + + // Expect the URL to be correct + await expect(page).toHaveURL(`${BASE_URL}/settings/notification-channels`); + + // Verify Email panel content + const emailPanel = page.locator('webstatus-notification-email-channels'); + await expect(emailPanel).toBeVisible(); + await expect(emailPanel).toContainText('test.user.1@example.com'); + await expect(emailPanel).toContainText('Enabled'); + + // Verify RSS panel content + const rssPanel = page.locator('webstatus-notification-rss-channels'); + await expect(rssPanel).toBeVisible(); + await expect(rssPanel).toContainText('Coming soon'); + + // Verify Webhook panel content + const webhookPanel = page.locator( + 'webstatus-notification-webhook-channels', + ); + await expect(webhookPanel).toBeVisible(); + await expect(webhookPanel).toContainText('Coming soon'); + + // Take a screenshot for visual regression + const pageContainer = page.locator('.page-container'); + await expect(pageContainer).toHaveScreenshot( + 'notification-channels-authenticated.png', + ); + }); +}); 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 new file mode 100644 index 000000000..c9922b225 Binary files /dev/null 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-firefox-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png new file mode 100644 index 000000000..a60ecc594 Binary files /dev/null and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png new file mode 100644 index 000000000..45af20445 Binary files /dev/null and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png differ diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png index 2dcb90025..3290834fb 100644 Binary files a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png and b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png differ diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png index 7b3d6e2cb..efbde13eb 100644 Binary files a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png and b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png differ diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png index 95dfba4cd..860e2a41b 100644 Binary files a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png and b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png differ diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 981aece17..7c88c7fa8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -44,6 +44,10 @@ http { try_files $uri $uri/ /index.html; } + location = /settings/notification-channels { + try_files $uri $uri/ /index.html; + } + location = / { try_files $uri $uri/ =404; } diff --git a/frontend/src/static/img/shoelace/assets/icons/envelope.svg b/frontend/src/static/img/shoelace/assets/icons/envelope.svg new file mode 100644 index 000000000..78bf1ded1 --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/envelope.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg b/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg new file mode 100644 index 000000000..8e24db05f --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg b/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg new file mode 100644 index 000000000..531e86cd0 --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/static/img/shoelace/assets/icons/rss.svg b/frontend/src/static/img/shoelace/assets/icons/rss.svg new file mode 100644 index 000000000..18dc9f1be --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/rss.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/static/img/shoelace/assets/icons/webhook.svg b/frontend/src/static/img/shoelace/assets/icons/webhook.svg new file mode 100644 index 000000000..6fbfb0543 --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/webhook.svg @@ -0,0 +1,5 @@ + + webhook + + + diff --git a/frontend/src/static/js/api/client.ts b/frontend/src/static/js/api/client.ts index 07d33430f..c60a39efe 100644 --- a/frontend/src/static/js/api/client.ts +++ b/frontend/src/static/js/api/client.ts @@ -54,6 +54,7 @@ export type BrowsersParameter = components['parameters']['browserPathParam']; type PageablePath = | '/v1/features' | '/v1/features/{feature_id}/stats/wpt/browsers/{browser}/channels/{channel}/{metric_view}' + | '/v1/users/me/notification-channels' | '/v1/stats/features/browsers/{browser}/feature_counts' | '/v1/users/me/saved-searches' | '/v1/stats/baseline_status/low_date_feature_counts'; @@ -421,6 +422,28 @@ export class APIClient { }); } + public async listNotificationChannels( + token: string, + ): Promise { + type NotificationChannelPage = SuccessResponsePageableData< + paths['/v1/users/me/notification-channels']['get'], + FetchOptions< + FilterKeys + >, + 'application/json', + '/v1/users/me/notification-channels' + >; + + return this.getAllPagesOfData< + '/v1/users/me/notification-channels', + NotificationChannelPage + >('/v1/users/me/notification-channels', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } + public async pingUser( token: string, pingOptions?: {githubToken?: string}, diff --git a/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts new file mode 100644 index 000000000..22faf8ac6 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts @@ -0,0 +1,86 @@ +/** + * 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 {assert, fixture, html} from '@open-wc/testing'; +import type {WebstatusNotificationEmailChannels} from '../../components/webstatus-notification-email-channels.js'; +import '../../components/webstatus-notification-email-channels.js'; +import {components} from 'webstatus.dev-backend'; +import {WebstatusNotificationPanel} from '../webstatus-notification-panel.js'; + +type NotificationChannelResponse = + components['schemas']['NotificationChannelResponse']; + +describe('webstatus-notification-email-channels', () => { + it('renders email channels correctly', async () => { + const mockChannels: NotificationChannelResponse[] = [ + { + id: '1', + type: 'email', + value: 'test1@example.com', + name: 'Email 1', + status: 'enabled', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + }, + { + id: '2', + type: 'email', + value: 'test2@example.com', + name: 'Email 2', + status: 'disabled', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + }, + ]; + + const el = await fixture(html` + + `); + + const emailItems = el.shadowRoot!.querySelectorAll('.channel-item'); + assert.equal(emailItems.length, mockChannels.length); + + // Test first email channel + const email1Name = emailItems[0].querySelector('.name'); + assert.include(email1Name!.textContent, 'test1@example.com'); + const email1Badge = emailItems[0].querySelector('sl-badge'); + assert.isNotNull(email1Badge); + assert.include(email1Badge!.textContent, 'Enabled'); + + // Test second email channel (disabled, so no badge) + const email2Name = emailItems[1].querySelector('.name'); + assert.include(email2Name!.textContent, 'test2@example.com'); + const email2Badge = emailItems[1].querySelector('sl-badge'); + assert.isNotNull(email2Badge); + assert.include(email2Badge!.textContent, 'Disabled'); + }); + + it('passes loading state to the base panel', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + assert.isTrue(basePanel!.loading); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-notification-panel.test.ts b/frontend/src/static/js/components/test/webstatus-notification-panel.test.ts new file mode 100644 index 000000000..cfda47b22 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notification-panel.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 {assert, fixture, html} from '@open-wc/testing'; +import '../../components/webstatus-notification-panel.js'; +import type {WebstatusNotificationPanel} from '../../components/webstatus-notification-panel.js'; + +describe('webstatus-notification-panel', () => { + it('renders content in the content slot', async () => { + const el = await fixture(html` + +
Test Content
+
+ `); + const contentSlot = el.shadowRoot!.querySelector( + 'slot[name="content"]', + ); + assert.isNotNull(contentSlot); + const assignedNodes = contentSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.include(assignedNodes[0].textContent, 'Test Content'); + }); + + it('renders an icon in the icon slot', async () => { + const el = await fixture(html` + + + + `); + const iconSlot = + el.shadowRoot!.querySelector('slot[name="icon"]'); + assert.isNotNull(iconSlot); + const assignedNodes = iconSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.equal(assignedNodes[0].tagName, 'SL-ICON'); + assert.equal(assignedNodes[0].getAttribute('name'), 'test-icon'); + }); + + it('renders a title in the title slot', async () => { + const el = await fixture(html` + + Test Title + + `); + const titleSlot = + el.shadowRoot!.querySelector('slot[name="title"]'); + assert.isNotNull(titleSlot); + const assignedNodes = titleSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.include(assignedNodes[0].textContent, 'Test Title'); + }); + + it('renders actions in the actions slot', async () => { + const el = await fixture(html` + + Action Button + + `); + const actionsSlot = el.shadowRoot!.querySelector( + 'slot[name="actions"]', + ); + assert.isNotNull(actionsSlot); + const assignedNodes = actionsSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.equal(assignedNodes[0].tagName, 'SL-BUTTON'); + assert.include(assignedNodes[0].textContent, 'Action Button'); + }); + + it('displays skeletons when loading is true and hides content', async () => { + const el = await fixture(html` + +
Test Content
+
+ `); + const skeletons = el.shadowRoot!.querySelectorAll('sl-skeleton'); + assert.equal(skeletons.length, 2); + const contentSlot = el.shadowRoot!.querySelector( + 'slot[name="content"]', + ); + assert.isNull(contentSlot); + }); + + it('hides skeletons when loading is false and shows content', async () => { + const el = await fixture(html` + +
Test Content
+
+ `); + const skeletons = el.shadowRoot!.querySelectorAll('sl-skeleton'); + assert.equal(skeletons.length, 0); + const contentSlot = el.shadowRoot!.querySelector( + 'slot[name="content"]', + ); + assert.isNotNull(contentSlot); + const assignedNodes = contentSlot.assignedNodes({ + flatten: true, + }) as HTMLElement[]; + assert.include(assignedNodes[0].textContent, 'Test Content'); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-notification-rss-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-rss-channels.test.ts new file mode 100644 index 000000000..93ab3fec8 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notification-rss-channels.test.ts @@ -0,0 +1,59 @@ +/** + * 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 {assert, fixture, html} from '@open-wc/testing'; +import type {WebstatusNotificationRssChannels} from '../../components/webstatus-notification-rss-channels.js'; +import '../../components/webstatus-notification-rss-channels.js'; +import '../../components/webstatus-notification-panel.js'; + +describe('webstatus-notification-rss-channels', () => { + it('displays "Coming soon" message', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + + const comingSoonText = basePanel!.querySelector( + '[slot="content"] p', + ) as HTMLParagraphElement; + assert.isNotNull(comingSoonText); + assert.include(comingSoonText.textContent, 'Coming soon'); + }); + + it('displays "Create RSS channel" button', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + + const createButton = basePanel!.querySelector( + '[slot="actions"] sl-button', + ) as HTMLButtonElement; + assert.isNotNull(createButton); + assert.include( + createButton.textContent!.trim().replace(/\s+/g, ' '), + 'Create RSS channel', + ); + }); +}); diff --git a/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts new file mode 100644 index 000000000..933cda626 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts @@ -0,0 +1,59 @@ +/** + * 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 {assert, fixture, html} from '@open-wc/testing'; +import type {WebstatusNotificationWebhookChannels} from '../../components/webstatus-notification-webhook-channels.js'; +import '../../components/webstatus-notification-webhook-channels.js'; +import '../../components/webstatus-notification-panel.js'; + +describe('webstatus-notification-webhook-channels', () => { + it('displays "Coming soon" message', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + + const comingSoonText = basePanel!.querySelector( + '[slot="content"] p', + ) as HTMLParagraphElement; + assert.isNotNull(comingSoonText); + assert.include(comingSoonText.textContent, 'Coming soon'); + }); + + it('displays "Create Webhook channel" button', async () => { + const el = await fixture(html` + + `); + + const basePanel = el.shadowRoot!.querySelector( + 'webstatus-notification-panel', + ); + assert.isNotNull(basePanel); + + const createButton = basePanel!.querySelector( + '[slot="actions"] sl-button', + ) as HTMLButtonElement; + assert.isNotNull(createButton); + assert.include( + createButton.textContent!.trim().replace(/\s+/g, ' '), + 'Create Webhook channel', + ); + }); +}); diff --git a/frontend/src/static/js/components/webstatus-notification-channels-page.ts b/frontend/src/static/js/components/webstatus-notification-channels-page.ts new file mode 100644 index 000000000..c524be7d1 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-channels-page.ts @@ -0,0 +1,118 @@ +/** + * 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 of a 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 {consume} from '@lit/context'; +import {LitElement, css, html} from 'lit'; +import {customElement, state} from 'lit/decorators.js'; +import {User} from 'firebase/auth'; +import {Task} from '@lit/task'; + +import {firebaseUserContext} from '../contexts/firebase-user-context.js'; +import {apiClientContext} from '../contexts/api-client-context.js'; +import {APIClient} from '../api/client.js'; +import {components} from 'webstatus.dev-backend'; +import {toast} from '../utils/toast.js'; +import {navigateToUrl} from '../utils/app-router.js'; + +import './webstatus-notification-email-channels.js'; +import './webstatus-notification-rss-channels.js'; +import './webstatus-notification-webhook-channels.js'; + +type NotificationChannelResponse = + components['schemas']['NotificationChannelResponse']; + +@customElement('webstatus-notification-channels-page') +export class WebstatusNotificationChannelsPage extends LitElement { + static styles = css` + .container { + display: flex; + flex-direction: column; + gap: 16px; + } + `; + + @consume({context: firebaseUserContext, subscribe: true}) + @state() + user: User | null | undefined; + + @consume({context: apiClientContext}) + @state() + apiClient!: APIClient; + + @state() + private emailChannels: NotificationChannelResponse[] = []; + + private _channelsTask = new Task(this, { + task: async () => { + if (this.user === null) { + navigateToUrl('/'); + void toast('You must be logged in to view this page.', 'danger'); + return; + } + if (this.user === undefined) { + return; + } + + const token = await this.user.getIdToken(); + const channels = await this.apiClient + .listNotificationChannels(token) + .catch(e => { + const errorMessage = e instanceof Error ? e.message : 'unknown error'; + void toast( + `Failed to load notification channels: ${errorMessage}`, + 'danger', + ); + return []; + }); + this.emailChannels = channels.filter(c => c.type === 'email'); + }, + args: () => [this.user], + }); + + render() { + return html` +
+ ${this._channelsTask.render({ + pending: () => html` + + + + + + + + + `, + + complete: () => html` + + + + + + + `, + error: e => { + const errorMessage = + e instanceof Error ? e.message : 'unknown error'; + return html`

Error: ${errorMessage}

`; + }, + })} +
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-email-channels.ts b/frontend/src/static/js/components/webstatus-notification-email-channels.ts new file mode 100644 index 000000000..60223adf8 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-email-channels.ts @@ -0,0 +1,97 @@ +/** + * 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, css, html} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {repeat} from 'lit/directives/repeat.js'; +import {components} from 'webstatus.dev-backend'; +import './webstatus-notification-panel.js'; + +type NotificationChannelResponse = + components['schemas']['NotificationChannelResponse']; + +@customElement('webstatus-notification-email-channels') +export class WebstatusNotificationEmailChannels extends LitElement { + static styles = css` + .channel-item { + background-color: #f9f9f9; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-bottom: 1px solid #e4e4e7; + } + + .channel-item:last-child { + border-bottom: none; + } + + .channel-info { + display: flex; + flex-direction: column; + } + + .channel-info .name { + font-size: 14px; + } + + .info-icon-button { + font-size: 1.2rem; + } + `; + + @property({type: Array}) + channels: NotificationChannelResponse[] = []; + + @property({type: Boolean}) + loading = false; + + render() { + return html` + + + Email +
+ + + +
+
+ ${repeat( + this.channels, + channel => channel.id, + channel => html` +
+
+ ${channel.value} +
+ ${channel.status === 'enabled' + ? html`Enabled` + : html`Disabled`} +
+ `, + )} +
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-panel.ts b/frontend/src/static/js/components/webstatus-notification-panel.ts new file mode 100644 index 000000000..c1d577000 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-panel.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2024 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, css, html} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +@customElement('webstatus-notification-panel') +export class WebstatusNotificationPanel extends LitElement { + static styles = css` + .card { + border: 1px solid #e4e4e7; + border-radius: 4px; + overflow: hidden; + } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + } + + .card-header .title { + display: flex; + align-items: center; + gap: 8px; + font-weight: bold; + font-size: 16px; + } + + .card-body { + padding: 0 20px 20px 20px; + } + + .loading-skeleton { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + } + `; + + @property({type: Boolean}) + loading = false; + + render() { + return html` +
+
+
+ + +
+
+ +
+
+
+ ${this.loading + ? html` +
+ + +
+ ` + : html``} +
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-rss-channels.ts b/frontend/src/static/js/components/webstatus-notification-rss-channels.ts new file mode 100644 index 000000000..a7d25d04b --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-rss-channels.ts @@ -0,0 +1,47 @@ +/** + * 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, css, html} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import './webstatus-notification-panel.js'; + +@customElement('webstatus-notification-rss-channels') +export class WebstatusNotificationRssChannels extends LitElement { + static styles = css` + .card-body { + padding: 20px; + color: #71717a; + } + `; + + render() { + return html` + + + RSS +
+ Create RSS + channel +
+
+

Coming soon

+
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts new file mode 100644 index 000000000..ac02f9b0a --- /dev/null +++ b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts @@ -0,0 +1,47 @@ +/** + * 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, css, html} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import './webstatus-notification-panel.js'; + +@customElement('webstatus-notification-webhook-channels') +export class WebstatusNotificationWebhookChannels extends LitElement { + static styles = css` + .card-body { + padding: 20px; + color: #71717a; + } + `; + + render() { + return html` + + + Webhook +
+ Create Webhook + channel +
+
+

Coming soon

+
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-sidebar-menu.ts b/frontend/src/static/js/components/webstatus-sidebar-menu.ts index 3b285ebfe..9e47c318a 100644 --- a/frontend/src/static/js/components/webstatus-sidebar-menu.ts +++ b/frontend/src/static/js/components/webstatus-sidebar-menu.ts @@ -50,11 +50,14 @@ import { savedSearchHelpers, } from '../contexts/app-bookmark-info-context.js'; import {TaskStatus} from '@lit/task'; +import {User} from 'firebase/auth'; +import {firebaseUserContext} from '../contexts/firebase-user-context.js'; // Map from sl-tree-item ids to paths. enum NavigationItemKey { FEATURES = 'features-item', STATISTICS = 'statistics-item', + NOTIFICATION_CHANNELS = 'notification-channels-item', } interface NavigationItem { @@ -75,6 +78,10 @@ const navigationMap: NavigationMap = { id: NavigationItemKey.STATISTICS, path: '/stats', }, + [NavigationItemKey.NOTIFICATION_CHANNELS]: { + id: NavigationItemKey.NOTIFICATION_CHANNELS, + path: '/settings/notification-channels', + }, }; interface GetLocationFunction { @@ -152,6 +159,10 @@ export class WebstatusSidebarMenu extends LitElement { @state() appBookmarkInfo?: AppBookmarkInfo; + @consume({context: firebaseUserContext, subscribe: true}) + @state() + user: User | null | undefined; + // For now, unconditionally open the features dropdown. @state() private isFeaturesDropdownExpanded: boolean = true; @@ -371,6 +382,28 @@ export class WebstatusSidebarMenu extends LitElement { `; } + renderSettingsMenu(): TemplateResult { + if (this.user === undefined) { + return html`${nothing}`; + } + if (this.user === null) { + return html`${nothing}`; + } + + return html` + + + + + Notification Channels + + + `; + } + render(): TemplateResult { return html` @@ -396,7 +429,7 @@ export class WebstatusSidebarMenu extends LitElement { Statistics --> - ${this.renderUserSavedSearches()} + ${this.renderUserSavedSearches()} ${this.renderSettingsMenu()} diff --git a/frontend/src/static/js/utils/app-router.ts b/frontend/src/static/js/utils/app-router.ts index 96f316b87..1b882c85f 100644 --- a/frontend/src/static/js/utils/app-router.ts +++ b/frontend/src/static/js/utils/app-router.ts @@ -21,6 +21,7 @@ import '../components/webstatus-feature-page.js'; import '../components/webstatus-stats-page.js'; import '../components/webstatus-notfound-error-page.js'; import '../components/webstatus-feature-gone-split-page.js'; +import '../components/webstatus-notification-channels-page.js'; export const initRouter = async (element: HTMLElement): Promise => { const router = new Router(element); @@ -37,6 +38,10 @@ export const initRouter = async (element: HTMLElement): Promise => { component: 'webstatus-stats-page', path: '/stats', }, + { + component: 'webstatus-notification-channels-page', + path: '/settings/notification-channels', + }, { component: 'webstatus-feature-gone-split-page', path: '/errors-410/feature-gone-split',