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`
`;
+ 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 {
!!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()}