diff --git a/package-lock.json b/package-lock.json index 761d067..b31a2f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "name": "url-classifier-exceptions-ui", "license": "MPL-2.0", "dependencies": { - "lit": "^3.3.0" + "addons-moz-compare": "^1.3.0", + "lit": "^3.3.0", + "mozjexl": "^1.1.6" }, "devDependencies": { "dotenv": "^17.0.0", @@ -789,6 +791,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/addons-moz-compare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/addons-moz-compare/-/addons-moz-compare-1.3.0.tgz", + "integrity": "sha512-/rXpQeaY0nOKhNx00pmZXdk5Mu+KhVlL3/pSBuAYwrxRrNiTvI/9xfQI8Lmm7DMMl+PDhtfAHY/0ibTpdeoQQQ==", + "license": "MPL-2.0" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -987,6 +995,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mozjexl": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/mozjexl/-/mozjexl-1.1.6.tgz", + "integrity": "sha512-34nvJEO7yLjMxooe0qfVUfBvotz270UFuhX0MmTM/0tR5HPJpjTYQLE7IyNag7EF6HPNmnBu0Y6kKoH6MvH8Lw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", diff --git a/package.json b/package.json index 04156f2..2943b2d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "vite": "^7.0.0" }, "dependencies": { - "lit": "^3.3.0" + "addons-moz-compare": "^1.3.0", + "lit": "^3.3.0", + "mozjexl": "^1.1.6" } } diff --git a/scripts/rs-config.d.ts b/scripts/rs-config.d.ts index 5619c67..adca2ce 100644 --- a/scripts/rs-config.d.ts +++ b/scripts/rs-config.d.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import type { RS_BASE_URLS } from "./rs-config.js"; - export type RSEnvironment = "dev" | "stage" | "prod"; export function isRSEnvValid(env: string | null): env is RSEnvironment; diff --git a/src/app.ts b/src/app.ts index 240709b..47d0eaf 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,54 +4,17 @@ import { LitElement, html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { ExceptionListEntry, BugMetaMap } from "./types"; -import { getRSEndpoint, RSEnvironment, isRSEnvValid } from "../scripts/rs-config.js"; -import "./exceptions-table/exceptions-table"; -import "./exceptions-table/top-exceptions-table"; -import "./github-corner"; +import { ExceptionListEntry, BugMetaMap, FirefoxChannel, FirefoxVersions } from "./types"; +import { getRSEndpoint, RSEnvironment } from "../scripts/rs-config.js"; +import "./components/exceptions-table/exceptions-table"; +import "./components/exceptions-table/top-exceptions-table"; +import "./components/github-corner.js"; +import "./components/settings-ui.js"; -const GITHUB_URL = "https://github.com/mozilla/url-classifier-exceptions-ui"; - -// Query parameter which can be used to override the RS environment. -const QUERY_PARAM_RS_ENV = "rs_env"; -const QUERY_PARAM_RS_USE_PREVIEW = "rs_preview"; - -/** - * Get the RS environment from URL parameters, falling back to the defaults if - * not specified. - * @returns The RS environment key - */ -function getRsEnv(): { env: RSEnvironment; usePreview: boolean } { - // Check if the environment is specified in the URL. - const params = new URLSearchParams(window.location.search); - const env = params.get(QUERY_PARAM_RS_ENV); - const usePreview = params.get(QUERY_PARAM_RS_USE_PREVIEW) === "true"; - if (isRSEnvValid(env)) { - return { env: env as RSEnvironment, usePreview }; - } - - // Fall back to build-time environment variable. - let viteEnv = import.meta.env.VITE_RS_ENVIRONMENT; - if (isRSEnvValid(viteEnv)) { - return { env: viteEnv as RSEnvironment, usePreview: false }; - } - - // Otherwise default to prod, non preview. - return { env: "prod", usePreview: false }; -} +import { versionNumberMatchesFilterExpression } from "./filter-expression/filter-expression.js"; +import settings from "./settings.js"; -/** - * Get the URL for the records endpoint for a given Remote Settings environment. - * @param rsUrl The URL of the Remote Settings environment. - * @returns The URL for the records endpoint. - */ -function getRecordsUrl(rsUrl: string): string { - // Allow ENV to override the URL for testing. - if (import.meta.env.VITE_RS_RECORDS_URL) { - return import.meta.env.VITE_RS_RECORDS_URL; - } - return rsUrl; -} +const GITHUB_URL = "https://github.com/mozilla/url-classifier-exceptions-ui"; /** * Fetch the records from the Remote Settings environment. @@ -59,7 +22,7 @@ function getRecordsUrl(rsUrl: string): string { * @returns The records. */ async function fetchRecords(rsUrl: string): Promise { - const response = await fetch(getRecordsUrl(rsUrl)); + const response = await fetch(settings.getRecordsUrl(rsUrl)); if (!response.ok) { throw new Error(`Failed to fetch records: ${response.statusText}`); } @@ -120,6 +83,36 @@ async function fetchBugMetadata(bugIds: Set): Promise { return bugMetaMap; } +/** + * Fetch the versions for each Firefox release channel. + * @returns The versions for each release channel. + */ +async function fetchVersionsPerChannel(): Promise { + let [nightly, beta, release] = await Promise.all([ + fetchVersionNumber("nightly"), + fetchVersionNumber("beta"), + fetchVersionNumber("release"), + ]); + return { + nightly, + beta, + release, + }; +} + +/** + * Fetch the version number for a given release channel. + * @param releaseChannel The release channel to fetch the version number for. + * @returns The version number for the given release channel. + */ +async function fetchVersionNumber(releaseChannel: string): Promise { + let url = new URL("https://whattrainisitnow.com/api/release/schedule/"); + url.searchParams.set("version", releaseChannel); + const response = await fetch(url); + const json = await response.json(); + return json.version; +} + @customElement("app-root") export class App extends LitElement { // The Remote Settings environment to use. The default is configured via env @@ -138,6 +131,11 @@ export class App extends LitElement { @state() records: ExceptionListEntry[] = []; + // Holds all fetched records matching the current Firefox version filter or + // all records if no filter is selected. + @state() + displayRecords: ExceptionListEntry[] = []; + // Holds the metadata for all bugs that are associated with the exceptions // list. @state() @@ -150,6 +148,16 @@ export class App extends LitElement { @state() error: string | null = null; + // Holds the version numbers for each Firefox release channel. This + // information is fetched via API on init. + @state() + firefoxVersions: FirefoxVersions | null = null; + + // The selected Firefox channel to filter entries by. If set to null, entries + // matching all Firefox versions are displayed. + @state() + filterFirefoxChannel: FirefoxChannel | null = null; + static styles = css` /* Sticky headings. */ h2 { @@ -197,9 +205,10 @@ export class App extends LitElement { super.connectedCallback(); // Set the initial RS environment and preview setting. - let { env, usePreview } = getRsEnv(); + let { env, usePreview } = settings.getRsEnv(); this.rsEnv = env; this.rsEnvUsePreview = usePreview; + this.filterFirefoxChannel = settings.getFirefoxChannelFilter(); this.init(); } @@ -211,21 +220,16 @@ export class App extends LitElement { try { this.loading = true; - const urlStr = getRSEndpoint(this.rsEnv, this.rsEnvUsePreview).toString(); - this.records = await fetchRecords(urlStr); + await this.updateVersionInfo(); - // Spot check if the format is as expected. - if (this.records.length && this.records[0].bugIds == null) { - throw new Error("Unexpected or outdated format."); + // If we failed to fetch version info, disable the filter. + // Version info is required to evaluate the RS filter expression. + if (!this.firefoxVersions) { + this.filterFirefoxChannel = null; } - // Sort so most recently modified records are at the top. - this.records.sort((a, b) => b.last_modified - a.last_modified); - - // Fetch the metadata for all bugs that are associated with the exceptions list. - this.bugMeta = await fetchBugMetadata( - new Set(this.records.flatMap((record) => record.bugIds || [])), - ); + // Fetch the records. + await this.updateRecords(); this.error = null; } catch (error: any) { @@ -235,37 +239,131 @@ export class App extends LitElement { } } + /** + * Fetch the versions for each Firefox release channel and store it. + */ + async updateVersionInfo() { + // Fetch the versions for each release channel. + try { + this.firefoxVersions = await fetchVersionsPerChannel(); + } catch (error) { + console.error("Failed to fetch Firefox versions", error); + } + } + + /** + * Fetch the records from the Remote Settings environment and store it. + */ + async updateRecords() { + const urlStr = getRSEndpoint(this.rsEnv, this.rsEnvUsePreview).toString(); + this.records = await fetchRecords(urlStr); + + // Spot check if the format is as expected. + if (this.records.length && this.records[0].bugIds == null) { + throw new Error("Unexpected or outdated format."); + } + + // Sort so most recently modified records are at the top. + this.records.sort((a, b) => b.last_modified - a.last_modified); + + // Update the filtered records based on the selected Firefox version. + let updateFilteredRecordsPromise = this.updateFilteredRecords(); + + // Fetch the metadata for all bugs that are associated with the exceptions list. + let fetchBugMetadataPromise = fetchBugMetadata( + new Set(this.records.flatMap((record) => record.bugIds || [])), + ).then((bugMeta) => { + this.bugMeta = bugMeta; + }); + + // Wait for both promises to complete. + await Promise.all([updateFilteredRecordsPromise, fetchBugMetadataPromise]); + } + + /** + * Updates the filtered records based on current filter settings. + * This should be called whenever records filterFirefoxChannel, or + * firefoxVersions change. + */ + private async updateFilteredRecords() { + // If no Firefox version is selected, show all records. + if (!this.filterFirefoxChannel || !this.firefoxVersions) { + this.displayRecords = this.records; + return; + } + + // Get the records that match the selected Firefox version. + const targetVersion = this.firefoxVersions[this.filterFirefoxChannel]; + this.displayRecords = await this.getRecordsMatchingFirefoxVersion(targetVersion); + } + /** * Get the number of unique bugs that are associated with the exceptions list * @returns The number of unique bugs. */ get uniqueBugCount(): number { - return new Set(this.records.flatMap((record) => record.bugIds || [])).size; + return new Set(this.displayRecords.flatMap((record) => record.bugIds || [])).size; } /** - * Handle changes to RS environment settings via the UI. - * @param event The change event either the dropdown or the checkbox. + * Get the records that match the given Firefox version. + * Uses the filter_expression field to match records. + * @param firefoxVersion The Firefox version to match. + * @returns The records that match the given Firefox version. */ - private handleRSEnvChange(event: Event) { - const target = event.target as HTMLSelectElement | HTMLInputElement; - - if (target.id === "rs-env") { - this.rsEnv = (target as HTMLSelectElement).value as RSEnvironment; - // Reset preview setting when environment changes. - this.rsEnvUsePreview = false; - } else if (target.id === "rs-env-preview") { - this.rsEnvUsePreview = (target as HTMLInputElement).checked; - } + private async getRecordsMatchingFirefoxVersion( + firefoxVersion: string, + ): Promise { + let filteredRecords = await Promise.all( + this.records.map(async (record) => { + let matches = await versionNumberMatchesFilterExpression( + firefoxVersion, + record.filter_expression, + ); + if (matches) { + return record; + } + return null; + }), + ); + return filteredRecords.filter((record) => record !== null); + } - // Update URL parameters to reflect current settings - const url = new URL(window.location.href); - url.searchParams.set(QUERY_PARAM_RS_ENV, this.rsEnv); - url.searchParams.set(QUERY_PARAM_RS_USE_PREVIEW, this.rsEnvUsePreview.toString()); - window.history.pushState({}, "", url); + /** + * Handle changes to RS environment settings via the settings component. + * @param event The RS environment change event. + */ + private async handleRSEnvChange(event: CustomEvent) { + this.rsEnv = event.detail.rsEnv; + this.rsEnvUsePreview = event.detail.rsEnvUsePreview; + + settings.setRsEnv(this.rsEnv, this.rsEnvUsePreview); // Fetch the records again with the new settings - this.init(); + try { + this.loading = true; + + await this.updateRecords(); + + this.error = null; + } catch (error: any) { + this.error = error?.message || "Failed to initialize"; + } finally { + this.loading = false; + } + } + + /** + * Handle changes to the Firefox channel filter via the settings component. + * @param event The Firefox channel filter change event. + */ + private async handleFirefoxChannelFilterChange(event: CustomEvent) { + this.filterFirefoxChannel = event.detail.filterFirefoxChannel; + settings.setFirefoxChannelFilter(this.filterFirefoxChannel); + + // Update the filtered records based on the selected Firefox version. + // This does not require a full re-fetch of the records. + await this.updateFilteredRecords(); } /** @@ -301,22 +399,23 @@ export class App extends LitElement { } if (this.loading) { - return html`
Loading...
`; + return html`

Loading...

`; } - if (this.records.length === 0) { - return html`
No records found.
`; + if (this.displayRecords.length === 0) { + return html`

No records found.

`; } return html`

- There are currently a total of ${this.records.length} exceptions on record. - ${this.records.filter((e) => !e.topLevelUrlPattern?.length).length} + There are currently a total of ${this.displayRecords.length} exceptions on record. + ${this.displayRecords.filter((e) => !e.topLevelUrlPattern?.length).length} global exceptions and - ${this.records.filter((e) => e.topLevelUrlPattern?.length).length} + ${this.displayRecords.filter((e) => e.topLevelUrlPattern?.length).length} per-site exceptions. ${this.records.filter((e) => e.category === "baseline").length} of them are baseline - exceptions and ${this.records.filter((e) => e.category === "convenience").length} - convenience exceptions. + >. ${this.displayRecords.filter((e) => e.category === "baseline").length} of them are + baseline exceptions and + ${this.displayRecords.filter((e) => e.category === "convenience").length} convenience + exceptions.

Overall the exceptions resolve ${this.uniqueBugCount} known bugs. Note that global @@ -333,7 +432,7 @@ export class App extends LitElement {

Baseline

!entry.topLevelUrlPattern?.length && entry.category === "baseline"} @@ -342,7 +441,7 @@ export class App extends LitElement {

Convenience

!entry.topLevelUrlPattern?.length && entry.category === "convenience"} @@ -359,7 +458,7 @@ export class App extends LitElement {

Baseline

!!entry.topLevelUrlPattern?.length && entry.category === "baseline"} @@ -368,7 +467,7 @@ export class App extends LitElement {

Convenience

!!entry.topLevelUrlPattern?.length && entry.category === "convenience"} @@ -381,7 +480,7 @@ export class App extends LitElement { that it should be added to the global exceptions list.

@@ -405,30 +504,22 @@ export class App extends LitElement { > for more information.

+ ${this.renderMainContent()}