diff --git a/i18n/english.js b/i18n/english.js index d4cdfdf8..2cb7ffe0 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -300,6 +300,8 @@ const ui = { preset_empty_no_license: "All packages have a license", preset_empty_deprecated: "No deprecated packages", preset_empty_large: "No package exceeds 100kb", + section_ignore_flags: "Ignore flags", + section_ignore_warnings: "Ignore warnings", nav_navigate: "navigate", nav_select: "select", nav_remove: "remove filter", diff --git a/i18n/french.js b/i18n/french.js index 020f0977..688baee9 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -300,6 +300,8 @@ const ui = { preset_empty_no_license: "Tous les packages ont une licence", preset_empty_deprecated: "Aucun package déprécié", preset_empty_large: "Aucun package ne dépasse 100ko", + section_ignore_flags: "Ignorer les flags", + section_ignore_warnings: "Ignorer les avertissements", nav_navigate: "naviguer", nav_select: "sélectionner", nav_remove: "supprimer le filtre", diff --git a/public/common/flags.js b/public/common/flags.js new file mode 100644 index 00000000..53da583e --- /dev/null +++ b/public/common/flags.js @@ -0,0 +1,17 @@ +// Import Third-party Dependencies +import { getManifest } from "@nodesecure/flags/web"; + +// CONSTANTS +export const IGNORABLE_FLAGS = new Set([ + "hasManyPublishers", + "hasIndirectDependencies", + "hasMissingOrUnusedDependency", + "isDead", + "isOutdated", + "hasDuplicate" +]); +export const FLAG_IGNORE_ITEMS = Object.values(getManifest()) + .filter(({ title }) => IGNORABLE_FLAGS.has(title)) + .map(({ title, emoji }) => { + return { value: title, label: title, emoji }; + }); diff --git a/public/components/command-palette/command-palette-panels.js b/public/components/command-palette/command-palette-panels.js index 465079bf..1bc8fe74 100644 --- a/public/components/command-palette/command-palette-panels.js +++ b/public/components/command-palette/command-palette-panels.js @@ -185,6 +185,33 @@ export function renderActions({ actions, onExecute }) { `; } +/** + * @param {{ title: string, items: Array<{value: string, label: string, emoji?: string}>, ignored: Set, onToggle: Function }} props + */ +export function renderIgnorePanel({ title, items, ignored, onToggle }) { + return html` +
+
${title}
+
+ ${repeat(items, (item) => item.value, (item) => { + const isIgnored = ignored.has(item.value); + + return html` +
onToggle(item.value)} + > + ${item.emoji ? html`${item.emoji}` : nothing} + ${item.label} +
+ `; + })} +
+
+ `; +} + /** * @param {{ results: Array, selectedIndex: number, helperCount: number, onFocus: Function }} props */ diff --git a/public/components/command-palette/command-palette.js b/public/components/command-palette/command-palette.js index d99b6472..0c9a9d70 100644 --- a/public/components/command-palette/command-palette.js +++ b/public/components/command-palette/command-palette.js @@ -1,6 +1,7 @@ // Import Third-party Dependencies import { LitElement, html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; +import { warnings } from "@nodesecure/js-x-ray/warnings"; // Import Internal Dependencies import { currentLang, vec2Distance } from "../../common/utils.js"; @@ -13,6 +14,7 @@ import { computeMatches, getHelperValues } from "./filters.js"; +import { FLAG_IGNORE_ITEMS } from "../../common/flags.js"; import { commandPaletteStyles } from "./command-palette-styles.js"; import { renderFlagPanel, @@ -21,6 +23,7 @@ import { renderFilterList, renderPresets, renderActions, + renderIgnorePanel, renderResults } from "./command-palette-panels.js"; import "./search-chip.js"; @@ -29,6 +32,10 @@ import "./search-chip.js"; const kActions = [ { id: "toggle_theme", shortcut: "t" } ]; +const kWarningItems = Object.keys(warnings) + .map((id) => { + return { value: id, label: id.replaceAll("-", " ") }; + }); function resolveKbd(shortcut) { if (!shortcut) { @@ -107,15 +114,23 @@ class CommandPalette extends LitElement { this.results = []; } + #onSettingsSaved = () => { + if (this.open) { + this.requestUpdate(); + } + }; + connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this.#handleKeydown); window.addEventListener(EVENTS.COMMAND_PALETTE_INIT, this.#init); + window.addEventListener(EVENTS.SETTINGS_SAVED, this.#onSettingsSaved); } disconnectedCallback() { document.removeEventListener("keydown", this.#handleKeydown); window.removeEventListener(EVENTS.COMMAND_PALETTE_INIT, this.#init); + window.removeEventListener(EVENTS.SETTINGS_SAVED, this.#onSettingsSaved); super.disconnectedCallback(); } @@ -360,6 +375,41 @@ class CommandPalette extends LitElement { this.#close(); } + #toggleIgnore(type, value) { + const ignoreSet = window.settings.config.ignore[type]; + if (ignoreSet.has(value)) { + ignoreSet.delete(value); + } + else { + ignoreSet.add(value); + } + + const config = window.settings.config; + fetch("/config", { + method: "put", + body: JSON.stringify({ + ...config, + ignore: { + warnings: [...config.ignore.warnings], + flags: [...config.ignore.flags] + } + }), + headers: { "content-type": "application/json" } + }).catch(console.error); + + window.dispatchEvent(new CustomEvent(EVENTS.SETTINGS_SAVED, { + detail: { + ...config, + ignore: { + warnings: config.ignore.warnings, + flags: config.ignore.flags + } + } + })); + + this.requestUpdate(); + } + #getEmptyQueryMessage() { const i18n = window.i18n[currentLang()].search_command; if (this.queries.length === 1) { @@ -538,6 +588,18 @@ class CommandPalette extends LitElement { actions: this.#resolveActions(), onExecute: (action) => this.#executeAction(action) }) : nothing} + ${showRichPlaceholder ? renderIgnorePanel({ + title: i18n.section_ignore_flags, + items: FLAG_IGNORE_ITEMS, + ignored: window.settings?.config?.ignore?.flags ?? new Set(), + onToggle: (value) => this.#toggleIgnore("flags", value) + }) : nothing} + ${showRichPlaceholder ? renderIgnorePanel({ + title: i18n.section_ignore_warnings, + items: kWarningItems, + ignored: window.settings?.config?.ignore?.warnings ?? new Set(), + onToggle: (value) => this.#toggleIgnore("warnings", value) + }) : nothing} ${renderResults({ results: this.results, selectedIndex: this.selectedIndex, diff --git a/public/components/views/settings/settings.js b/public/components/views/settings/settings.js index 8de1967a..6b49122a 100644 --- a/public/components/views/settings/settings.js +++ b/public/components/views/settings/settings.js @@ -2,11 +2,11 @@ import { LitElement, html, css, nothing } from "lit"; import { getJSON } from "@nodesecure/vis-network"; import { warnings } from "@nodesecure/js-x-ray/warnings"; -import { getManifest } from "@nodesecure/flags/web"; // Import Internal Dependencies import { EVENTS } from "../../../core/events.js"; import { currentLang } from "../../../common/utils.js"; +import { FLAG_IGNORE_ITEMS } from "../../../common/flags.js"; // CONSTANTS const kAllowedHotKeys = new Set([ @@ -25,19 +25,6 @@ const kDefaultHotKeys = { warnings: "A" }; const kShortcutInputTargetIds = new Set(Object.keys(kDefaultHotKeys)); -const kIgnorableFlags = new Set([ - "hasManyPublishers", - "hasIndirectDependencies", - "hasMissingOrUnusedDependency", - "isDead", - "isOutdated", - "hasDuplicate" -]); -const kFlags = Object.values(getManifest()) - .filter(({ title }) => kIgnorableFlags.has(title)) - .map(({ title, emoji }) => { - return { value: title, emoji }; - }); const kShortcuts = [ { id: "home", labelKey: "goto", viewKey: "home" }, { id: "network", labelKey: "goto", viewKey: "network" }, @@ -304,6 +291,21 @@ export class SettingsView extends LitElement { } } + #onSettingsSaved = (event) => { + this.setNewConfig(event.detail); + this._saveEnabled = false; + }; + + connectedCallback() { + super.connectedCallback(); + window.addEventListener(EVENTS.SETTINGS_SAVED, this.#onSettingsSaved); + } + + disconnectedCallback() { + window.removeEventListener(EVENTS.SETTINGS_SAVED, this.#onSettingsSaved); + super.disconnectedCallback(); + } + firstUpdated() { this.updateNavigationHotKey(this._hotkeys); } @@ -464,7 +466,7 @@ export class SettingsView extends LitElement { } #renderFlagCheckboxes() { - return kFlags.map(({ value, emoji }) => html` + return FLAG_IGNORE_ITEMS.map(({ value, emoji }) => html`
{ test("shows contextual empty message for has-vulnerabilities preset", async({ page }) => { await page.locator(".range-preset").filter({ hasText: i18n.preset_has_vulnerabilities }).click(); - await expect(page.locator(".empty-state")).toHaveText(i18n.preset_empty_has_vulnerabilities); + await expect(page.locator(".dialog .empty-state")).toHaveText(i18n.preset_empty_has_vulnerabilities); }); test("shows contextual empty message for deprecated preset", async({ page }) => { await page.locator(".range-preset").filter({ hasText: i18n.preset_deprecated }).click(); - await expect(page.locator(".empty-state")).toHaveText(i18n.preset_empty_deprecated); + await expect(page.locator(".dialog .empty-state")).toHaveText(i18n.preset_empty_deprecated); }); test("shows generic empty message when a manual filter yields no results", async({ page }) => { await page.locator("#cmd-input").fill("flag:hasBannedFile"); await page.keyboard.press("Enter"); - await expect(page.locator(".empty-state")).toHaveText(i18n.empty_after_filter); + await expect(page.locator(".dialog .empty-state")).toHaveText(i18n.empty_after_filter); }); test("clicking the theme action closes the palette and toggles the theme", async({ page }) => { @@ -90,3 +90,110 @@ test.describe("[command-palette] presets and actions", () => { await expect(page.locator(".backdrop")).not.toBeVisible(); }); }); + +test.describe("[command-palette] ignore flags and warnings", () => { + let i18n; + + test.beforeEach(async({ page }) => { + await page.goto("/"); + await page.waitForSelector(`[data-menu="network--view"].active`); + + i18n = await page.evaluate(() => { + const lang = document.getElementById("lang").dataset.lang; + const activeLang = lang in window.i18n ? lang : "english"; + + return window.i18n[activeLang].search_command; + }); + + await page.locator("body").click(); + await page.keyboard.press("Control+k"); + + await expect(page.locator(".backdrop")).toBeVisible(); + }); + + test("renders the ignore flags section", async({ page }) => { + await expect(page.locator(".section").filter({ hasText: i18n.section_ignore_flags })).toBeVisible(); + }); + + test("renders the ignore warnings section", async({ page }) => { + await expect(page.locator(".section").filter({ hasText: i18n.section_ignore_warnings })).toBeVisible(); + }); + + test("renders all six ignorable flag chips", async({ page }) => { + const ignoreFlagsSection = page.locator(".section").filter({ hasText: i18n.section_ignore_flags }); + + await expect(ignoreFlagsSection.locator(".flag-chip")).toHaveCount(6); + }); + + test("renders at least one warning chip", async({ page }) => { + const ignoreWarningsSection = page.locator(".section").filter({ hasText: i18n.section_ignore_warnings }); + + expect(await ignoreWarningsSection.locator(".flag-chip").count()).toBeGreaterThan(0); + }); + + test("clicking a flag chip marks it as ignored", async({ page }) => { + const ignoreFlagsSection = page.locator(".section").filter({ hasText: i18n.section_ignore_flags }); + const chip = ignoreFlagsSection.locator(".flag-chip[title='isOutdated']"); + + const isInitiallyIgnored = await page.evaluate( + () => window.settings.config.ignore.flags.has("isOutdated") + ); + if (isInitiallyIgnored) { + await chip.click(); + await expect(chip).not.toContainClass("flag-active"); + } + + await chip.click(); + + await expect(chip).toContainClass("flag-active"); + const ignoredFlags = await page.evaluate(() => [...window.settings.config.ignore.flags]); + expect(ignoredFlags).toContain("isOutdated"); + }); + + test("clicking an active flag chip removes it from ignored", async({ page }) => { + const ignoreFlagsSection = page.locator(".section").filter({ hasText: i18n.section_ignore_flags }); + const chip = ignoreFlagsSection.locator(".flag-chip[title='isOutdated']"); + + const isInitiallyIgnored = await page.evaluate( + () => window.settings.config.ignore.flags.has("isOutdated") + ); + if (!isInitiallyIgnored) { + await chip.click(); + await expect(chip).toContainClass("flag-active"); + } + + await chip.click(); + + await expect(chip).not.toContainClass("flag-active"); + const ignoredFlags = await page.evaluate(() => [...window.settings.config.ignore.flags]); + expect(ignoredFlags).not.toContain("isOutdated"); + }); + + test("clicking a warning chip marks it as ignored", async({ page }) => { + const ignoreWarningsSection = page.locator(".section").filter({ hasText: i18n.section_ignore_warnings }); + const chip = ignoreWarningsSection.locator(".flag-chip").first(); + const warningValue = await chip.getAttribute("title"); + + const isInitiallyIgnored = await page.evaluate( + (id) => window.settings.config.ignore.warnings.has(id), + warningValue + ); + if (isInitiallyIgnored) { + await chip.click(); + await expect(chip).not.toContainClass("flag-active"); + } + + await chip.click(); + + await expect(chip).toContainClass("flag-active"); + const ignoredWarnings = await page.evaluate(() => [...window.settings.config.ignore.warnings]); + expect(ignoredWarnings).toContain(warningValue); + }); + + test("ignore sections are hidden when a filter is active", async({ page }) => { + await page.locator("#cmd-input").fill("flag:"); + + await expect(page.locator(".section").filter({ hasText: i18n.section_ignore_flags })).not.toBeVisible(); + await expect(page.locator(".section").filter({ hasText: i18n.section_ignore_warnings })).not.toBeVisible(); + }); +});