diff --git a/README.md b/README.md index 9473bf8d..0671430e 100644 --- a/README.md +++ b/README.md @@ -136,24 +136,20 @@ Our back-end scanner package is available [here](https://github.com/NodeSecure/s Flags and emojis legends are documented [here](https://github.com/NodeSecure/flags/blob/main/FLAGS.md). -## Searchbar filters +## Search command -Since version **0.6.0**, the UI includes a brand new search bar that allows you to search anything within the tree (graph) using multiple criteria (filters). The currently available filters are: +Press `Cmd+K` (macOS) or `Ctrl+K` (Windows/Linux) from the network view to open the search command. It lets you filter the dependency graph using one or more criteria simultaneously. -- package (**the default filter if there is none**). -- version (take a semver range as an argument). -- flag (list of available flags in the current payload/tree). -- license (list of available licenses in the current payload/tree). -- author (author name/email/url). -- ext (list of available file extensions in the current payload/tree). -- builtin (available Node.js core module name). -- size (see [here](https://github.com/NodeSecure/size-satisfies#usage-example)). +Type a package name directly to search, or prefix with a filter name followed by `:` to use a specific filter: -Exemple of query: - -``` -version: >=1.2 | 2, ext: .js, builtin: fs -``` +- `package` — **default when no prefix is given**, matches by name. +- `version` — semver range (e.g. `>=1.2.0`, `^2.0.0`). +- `flag` — select from the list of flags present in the current tree. +- `license` — SPDX identifier (e.g. `MIT`, `Apache-2.0`). +- `author` — author name or email. +- `ext` — file extension present in the package (e.g. `.js`, `.ts`). +- `builtin` — Node.js core module used by the package (e.g. `fs`, `path`). +- `size` — size range (see [size-satisfies](https://github.com/NodeSecure/size-satisfies#usage-example), e.g. `>50kb`, `10kb..200kb`). ## FAQ diff --git a/i18n/arabic.js b/i18n/arabic.js index 1f9390ac..09bde598 100644 --- a/i18n/arabic.js +++ b/i18n/arabic.js @@ -159,7 +159,6 @@ const ui = { thirdPartyTools: "أدوات الطرف الثالث" } }, - searchbar_placeholder: "بحث", loading_nodes: "... جاري تحميل العقد ...", please_wait: "(يرجى الانتظار)", popup: { diff --git a/i18n/english.js b/i18n/english.js index 9ad46300..d50cd2dc 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -161,7 +161,6 @@ const ui = { thirdPartyTools: "Third-party tools" } }, - searchbar_placeholder: "Search", loading_nodes: "... Loading nodes ...", please_wait: "(Please wait)", popup: { @@ -235,6 +234,37 @@ const ui = { packageLengthErr: "Package name must be between 2 and 64 characters.", registryPlaceholder: "Search packages" }, + search_command: { + placeholder: "Search packages...", + placeholder_filter_hint: "or use", + placeholder_refine: "Add another filter...", + section_filters: "Filters", + section_flags: "Flags - click to toggle", + section_size: "Size - select a preset or type above", + section_version: "Version - select a preset or type above", + section_packages: "Packages", + section_licenses: "Available licenses", + section_extensions: "File extensions", + section_builtins: "Node.js core modules", + section_authors: "Authors", + hint_size: "e.g. >50kb, 10kb..200kb", + hint_version: "e.g. ^1.0.0, >=2.0.0", + empty: "No results found", + nav_navigate: "navigate", + nav_select: "select", + nav_remove: "remove filter", + nav_close: "close", + filter_hints: { + package: "name", + version: "semver range", + flag: "click to select", + license: "SPDX identifier", + author: "name or email", + ext: "file extension", + builtin: "node.js module", + size: "e.g. >50kb" + } + }, legend: { default: "The package is fine.", warn: "The package has warnings.", diff --git a/i18n/french.js b/i18n/french.js index 0729487a..817d559f 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -161,7 +161,6 @@ const ui = { thirdPartyTools: "Outils tiers" } }, - searchbar_placeholder: "Recherche", loading_nodes: "... Chargement des noeuds ...", please_wait: "(Merci de patienter)", popup: { @@ -235,6 +234,37 @@ const ui = { packageLengthErr: "Le nom du package doit être compris entre 2 et 64 caractères.", registryPlaceholder: "Recherche de packages" }, + search_command: { + placeholder: "Rechercher des packages...", + placeholder_filter_hint: "ou utiliser", + placeholder_refine: "Ajouter un autre filtre...", + section_filters: "Filtres", + section_flags: "Flags - cliquer pour activer", + section_size: "Taille - choisir un préréglage ou saisir ci-dessus", + section_version: "Version - choisir un préréglage ou saisir ci-dessus", + section_packages: "Packages", + section_licenses: "Licences disponibles", + section_extensions: "Extensions de fichiers", + section_builtins: "Modules Node.js natifs", + section_authors: "Auteurs", + hint_size: "ex. >50kb, 10kb..200kb", + hint_version: "ex. ^1.0.0, >=2.0.0", + empty: "Aucun résultat trouvé", + nav_navigate: "naviguer", + nav_select: "sélectionner", + nav_remove: "supprimer le filtre", + nav_close: "fermer", + filter_hints: { + package: "nom", + version: "range semver", + flag: "cliquer pour sélectionner", + license: "identifiant SPDX", + author: "nom ou email", + ext: "extension de fichier", + builtin: "module node.js", + size: "ex. >50kb" + } + }, legend: { default: "Rien à signaler.", warn: "La dépendance contient des menaces.", diff --git a/i18n/turkish.js b/i18n/turkish.js index 523c038c..3f9c27e6 100644 --- a/i18n/turkish.js +++ b/i18n/turkish.js @@ -161,7 +161,6 @@ const ui = { thirdPartyTools: "Üçüncü taraf araçlar" } }, - searchbar_placeholder: "Ara", loading_nodes: "... Düğümler yükleniyor ...", please_wait: "(Lütfen bekleyin)", popup: { diff --git a/public/components/navigation/navigation.js b/public/components/navigation/navigation.js index 1e692a1e..acaf968c 100644 --- a/public/components/navigation/navigation.js +++ b/public/components/navigation/navigation.js @@ -35,7 +35,8 @@ export class ViewNavigation { const isTargetPopup = event.target.id === "popup--background"; const isPopupOpened = document.querySelector("#popup--background.show"); const isTargetInput = event.target.tagName === "INPUT"; - if (isTargetPopup || isWikiOpen || isTargetInput || isPopupOpened) { + const isSearchCommandOpen = Boolean(document.querySelector("search-command")?.open); + if (isTargetPopup || isWikiOpen || isTargetInput || isPopupOpened || isSearchCommandOpen) { return; } @@ -75,11 +76,6 @@ export class ViewNavigation { selectedNav.classList.add("active"); this.setAnchor(menuName); - const searchbar = document.getElementById("searchbar"); - if (searchbar) { - searchbar.style.display = menuName === "network--view" ? "flex" : "none"; - } - this.activeMenu = selectedNav; } diff --git a/public/components/search-command/filters.js b/public/components/search-command/filters.js new file mode 100644 index 00000000..6e05e27f --- /dev/null +++ b/public/components/search-command/filters.js @@ -0,0 +1,242 @@ +// Import Third-party Dependencies +import semver from "semver"; +import sizeSatisfies from "@nodesecure/size-satisfies"; +import { getManifestEmoji } from "@nodesecure/flags/web"; + +// CONSTANTS +export const FLAG_LIST = [...getManifestEmoji()].map(([name, emoji]) => { + return { + emoji, + name + }; +}); +export const SIZE_PRESETS = [ + { label: "< 10 kb", value: "<10kb" }, + { label: "10 – 100 kb", value: "10kb..100kb" }, + { label: "> 100 kb", value: ">100kb" } +]; +export const VERSION_PRESETS = [ + { label: "0.x", value: "0.x" }, + { label: "≥ 1.0", value: ">=1.0.0" }, + { label: "< 1.0", value: "<1.0.0" } +]; +export const FILTERS_NAME = new Set(["package", "version", "flag", "license", "author", "ext", "builtin", "size"]); +// Filters that use a searchable text-based list (not a rich visual panel) +export const FILTER_HAS_HELPERS = new Set(["license", "ext", "builtin", "author"]); +// Filters where the mode persists after selection (multi-select) +export const FILTER_MULTI_SELECT = new Set(["flag"]); + +/** + * Returns per-flag package counts across the full linker. + * + * @param {Map} linker + * @returns {Map} + */ +export function getFlagCounts(linker) { + const counts = new Map(); + for (const { flags } of linker.values()) { + for (const flag of flags) { + counts.set(flag, (counts.get(flag) ?? 0) + 1); + } + } + + return counts; +} + +/** + * Returns per-value package counts for list-type filters. + * + * @param {Map} linker + * @param {string} filterName + * @returns {Map} + */ +export function getFilterValueCounts(linker, filterName) { + const counts = new Map(); + for (const opt of linker.values()) { + for (const value of getValuesForCount(opt, filterName)) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + } + + return counts; +} + +function getValuesForCount(opt, filterName) { + switch (filterName) { + case "license": + return opt.uniqueLicenseIds ?? []; + case "ext": + return opt.composition.extensions.filter((ext) => ext !== ""); + case "builtin": + return opt.composition.required_nodejs; + case "author": { + if (opt.author === null) { + return []; + } + const name = typeof opt.author === "string" ? opt.author : opt.author?.name; + + return name ? [name] : []; + } + default: + return []; + } +} + +/** + * @param {Map} linker + * @param {string} filterName + * @returns {{ display: string, value: string }[]} + */ +export function getHelperValues(linker, filterName) { + switch (filterName) { + case "license": { + const items = new Set(); + for (const { uniqueLicenseIds = [] } of linker.values()) { + for (const id of uniqueLicenseIds) { + items.add(id); + } + } + + return [...items].map((licenseId) => { + return { display: licenseId, value: licenseId }; + }); + } + case "ext": { + const items = new Set(); + for (const { composition } of linker.values()) { + for (const ext of composition.extensions) { + items.add(ext); + } + } + + items.delete(""); + + return [...items].map((ext) => { + return { display: ext, value: ext }; + }); + } + case "builtin": { + const items = new Set(); + for (const { composition } of linker.values()) { + for (const module of composition.required_nodejs) { + items.add(module); + } + } + + return [...items].map((module) => { + return { display: module, value: module }; + }); + } + case "author": { + const items = new Set(); + for (const { author } of linker.values()) { + if (author === null) { + continue; + } + + if (typeof author === "string") { + items.add(author); + } + else if (Object.hasOwn(author, "name")) { + items.add(author.name); + } + } + + return [...items].map((name) => { + return { display: name, value: name }; + }); + } + default: + return []; + } +} + +/** + * Returns the Set of package IDs (as strings) matching the given filter+value. + * + * @param {Map} linker + * @param {string} filterName + * @param {string} inputValue + * @returns {Set} + */ +export function computeMatches(linker, filterName, inputValue) { + const matchingIds = new Set(); + + for (const [id, opt] of linker) { + if (matchesFilter(opt, filterName, inputValue)) { + matchingIds.add(String(id)); + } + } + + return matchingIds; +} + +function matchesFilter(opt, filterName, inputValue) { + switch (filterName) { + case "package": { + try { + return new RegExp(inputValue, "gi").test(opt.name); + } + catch { + return false; + } + } + case "version": { + try { + return semver.satisfies(opt.version, inputValue); + } + catch { + return false; + } + } + case "license": { + try { + const regex = new RegExp(inputValue, "gi"); + + return opt.uniqueLicenseIds.some((licenseId) => regex.test(licenseId)); + } + catch { + return false; + } + } + case "ext": { + const extensions = new Set(opt.composition.extensions); + const wanted = inputValue.startsWith(".") ? inputValue : `.${inputValue}`; + + return extensions.has(wanted.toLowerCase()); + } + case "size": { + try { + return sizeSatisfies(inputValue, opt.size); + } + catch { + return false; + } + } + case "builtin": { + try { + const regex = new RegExp(inputValue, "gi"); + + return opt.composition.required_nodejs.some((mod) => regex.test(mod)); + } + catch { + return false; + } + } + case "author": { + try { + const regex = new RegExp(inputValue, "gi"); + + return (typeof opt.author === "string" && regex.test(opt.author)) || + (opt.author !== null && Object.hasOwn(opt.author, "name") && regex.test(opt.author.name)); + } + catch { + return false; + } + } + case "flag": + return opt.flags.includes(inputValue); + default: + return false; + } +} diff --git a/public/components/search-command/search-chip.js b/public/components/search-command/search-chip.js new file mode 100644 index 00000000..c1a748e3 --- /dev/null +++ b/public/components/search-command/search-chip.js @@ -0,0 +1,76 @@ +// Import Third-party Dependencies +import { LitElement, html, css } from "lit"; + +class SearchChip extends LitElement { + static styles = css` +:host { + --sc-chip-bg: #e2e8f0; + --sc-chip-filter: #3722af; + --sc-chip-value: #64748b; + --sc-chip-remove: #94a3b8; + + display: inline-flex; +} + +:host-context(body.dark) { + --sc-chip-bg: #37474f; + --sc-chip-filter: #e1f5fe; + --sc-chip-value: #b0bec5; + --sc-chip-remove: #b0bec5; +} + +.chip { + display: flex; + align-items: center; + gap: 4px; + background: var(--sc-chip-bg); + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; + font-family: mononoki, monospace; +} + +.chip b { + color: var(--sc-chip-filter); +} + +.chip span { + color: var(--sc-chip-value); +} + +.chip-remove { + background: none; + border: none; + color: var(--sc-chip-remove); + cursor: pointer; + padding: 0 2px; + font-size: 14px; + line-height: 1; +} + +.chip-remove:hover { + color: #ef5350; +} +`; + + static properties = { + filter: { type: String }, + value: { type: String } + }; + + #onRemove = () => { + this.dispatchEvent(new CustomEvent("remove", { bubbles: true, composed: true })); + }; + + render() { + return html` +
+ ${this.filter}: + ${this.value} + +
+ `; + } +} + +customElements.define("search-chip", SearchChip); diff --git a/public/components/search-command/search-command-panels.js b/public/components/search-command/search-command-panels.js new file mode 100644 index 00000000..86394207 --- /dev/null +++ b/public/components/search-command/search-command-panels.js @@ -0,0 +1,166 @@ +// Import Third-party Dependencies +import { html, nothing } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import { classMap } from "lit/directives/class-map.js"; + +// Import Internal Dependencies +import { currentLang } from "../../common/utils.js"; +import { + FLAG_LIST, + SIZE_PRESETS, + VERSION_PRESETS, + getFlagCounts, + getFilterValueCounts +} from "./filters.js"; + +// CONSTANTS +const kListTitleKeys = { + license: "section_licenses", + ext: "section_extensions", + builtin: "section_builtins", + author: "section_authors" +}; + +/** + * @param {{ linker: Map, queries: Array, inputValue: string, onAdd: Function, onRemove: Function }} props + */ +export function renderFlagPanel({ linker, queries, inputValue, onAdd, onRemove }) { + const i18n = window.i18n[currentLang()].search_command; + const flagCounts = getFlagCounts(linker); + const activeFlags = new Set( + queries + .filter((query) => query.filter === "flag") + .map((query) => query.value) + ); + + const searchText = inputValue.slice("flag:".length).toLowerCase(); + const visibleFlags = searchText === "" + ? FLAG_LIST + : FLAG_LIST.filter((flag) => flag.name.toLowerCase().includes(searchText)); + + return html` +
+
${i18n.section_flags}
+
+ ${repeat(visibleFlags, (flag) => flag.name, (flag) => { + const count = flagCounts.get(flag.name) ?? 0; + const isActive = activeFlags.has(flag.name); + + return html` +
(isActive ? onRemove("flag", flag.name) : onAdd("flag", flag.name))} + > + ${flag.emoji} + ${flag.name} + ${count > 0 ? html`${count}` : nothing} +
+ `; + })} +
+
+ `; +} + +/** + * @param {{ activeFilter: string, onAdd: Function }} props + */ +export function renderRangePanel({ activeFilter, onAdd }) { + const i18n = window.i18n[currentLang()].search_command; + const isSizeFilter = activeFilter === "size"; + const presets = isSizeFilter ? SIZE_PRESETS : VERSION_PRESETS; + const title = isSizeFilter ? i18n.section_size : i18n.section_version; + const hint = isSizeFilter ? i18n.hint_size : i18n.hint_version; + + return html` +
+
${title}
+
+
+ ${presets.map((preset) => html` + + `)} +
+
${hint}
+
+
+ `; +} + +/** + * @param {{ linker: Map, activeFilter: string, helpers: Array, selectedIndex: number, onAdd: Function }} props + */ +export function renderListPanel({ linker, activeFilter, helpers, selectedIndex, onAdd }) { + const i18n = window.i18n[currentLang()].search_command; + const counts = getFilterValueCounts(linker, activeFilter); + const title = i18n[kListTitleKeys[activeFilter]] ?? activeFilter; + + return html` +
+
${title}
+ ${repeat(helpers, (helper) => helper.value, (helper, i) => html` +
onAdd(activeFilter, helper.value)} + > + ${helper.display} + ${counts.has(helper.value) ? html`${counts.get(helper.value)}` : nothing} +
+ `)} +
+ `; +} + +/** + * @param {{ helpers: Array, selectedIndex: number, onSelect: Function }} props + */ +export function renderFilterList({ helpers, selectedIndex, onSelect }) { + const i18n = window.i18n[currentLang()].search_command; + + return html` +
+
${i18n.section_filters}
+ ${repeat(helpers, (helper) => helper.value, (helper, i) => html` +
onSelect(helper)} + > + ${helper.display}${helper.hint} +
+ `)} +
+ `; +} + +/** + * @param {{ results: Array, selectedIndex: number, helperCount: number, onFocus: Function }} props + */ +export function renderResults({ results, selectedIndex, helperCount, onFocus }) { + if (results.length === 0) { + return nothing; + } + + const i18n = window.i18n[currentLang()].search_command; + + return html` +
+
+ ${i18n.section_packages} ${results.length} +
+ ${repeat(results, (result) => result.id, (result, i) => html` +
onFocus(result.id)} + > + ${result.flags} + ${result.name} + ${result.version} +
+ `)} +
+ `; +} diff --git a/public/components/search-command/search-command-styles.js b/public/components/search-command/search-command-styles.js new file mode 100644 index 00000000..9d2d4ee3 --- /dev/null +++ b/public/components/search-command/search-command-styles.js @@ -0,0 +1,384 @@ +// Import Third-party Dependencies +import { css } from "lit"; + +// Import Internal Dependencies +import { scrollbarStyle } from "../../common/scrollbar-style.js"; + +export const searchCommandStyles = [ + scrollbarStyle, + css` +:host { + --sc-bg: #ffffff; + --sc-bg-item: #f8fafc; + --sc-bg-hover: #eff4ff; + --sc-backdrop: rgb(0 0 0 / 40%); + --sc-border: #e2e8f0; + --sc-border-subtle: #f1f5f9; + --sc-shadow: 0 20px 60px rgb(0 0 0 / 20%); + --sc-text: #1e293b; + --sc-text-muted: #94a3b8; + --sc-text-accent: #3722af; + --sc-text-hint: #94a3b8; + --sc-text-version: #1976d2; + --sc-caret: #3722af; + --sc-flag-bg: #f8fafc; + --sc-flag-active-bg: #eff4ff; + --sc-flag-active-border: #3722af; + --sc-flag-active-text: #3722af; + --sc-flag-count-bg: #e2e8f0; + --sc-flag-count-text: #64748b; + --sc-flag-active-count-bg: #c7d7fe; + --sc-flag-active-count-text: #3722af; + --sc-code-bg: #f1f5f9; + --sc-code-border: #e2e8f0; + --sc-code-text: #475569; + --sc-kbd-bg: #f1f5f9; + --sc-kbd-text: #475569; + --sc-count-bg: #f1f5f9; + + position: fixed; + inset: 0; + z-index: 200; + pointer-events: none; +} + +:host-context(body.dark) { + --sc-bg: #1e2030; + --sc-bg-item: #263238; + --sc-bg-hover: #1a2744; + --sc-backdrop: rgb(0 0 0 / 60%); + --sc-border: #37474f; + --sc-border-subtle: #263238; + --sc-shadow: 0 20px 60px rgb(0 0 0 / 60%); + --sc-text: #eceff1; + --sc-text-muted: #546e7a; + --sc-text-accent: #e1f5fe; + --sc-text-hint: #546e7a; + --sc-text-version: #ffeb3b; + --sc-caret: #5c6bc0; + --sc-flag-bg: #263238; + --sc-flag-active-bg: #0d2137; + --sc-flag-active-border: #1976d2; + --sc-flag-active-text: #e1f5fe; + --sc-flag-count-bg: #37474f; + --sc-flag-count-text: #78909c; + --sc-flag-active-count-bg: #1565c0; + --sc-flag-active-count-text: #b3d4ff; + --sc-code-bg: #263238; + --sc-code-border: #37474f; + --sc-code-text: #78909c; + --sc-kbd-bg: #37474f; + --sc-kbd-text: #b0bec5; + --sc-count-bg: #37474f; +} + +.backdrop { + position: fixed; + inset: 0; + background: var(--sc-backdrop); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 80px; + pointer-events: all; +} + +.dialog { + background: var(--sc-bg); + border: 1px solid var(--sc-border); + border-radius: 8px; + width: 640px; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 160px); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: var(--sc-shadow); + pointer-events: all; +} + +.search-header { + padding: 12px 16px; + border-bottom: 1px solid var(--sc-border); +} + +.search-input-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.input-wrapper { + position: relative; + flex: 1; + min-width: 200px; + display: flex; + align-items: center; +} + +#cmd-input { + width: 100%; + background: none; + border: none; + outline: none; + color: var(--sc-text); + font-size: 15px; + font-family: mononoki, monospace; + caret-color: var(--sc-caret); +} + +.cmd-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + color: var(--sc-text-muted); + font-size: 15px; + font-family: mononoki, monospace; + pointer-events: none; + white-space: nowrap; + overflow: hidden; + gap: 4px; +} + +.cmd-placeholder code { + background: var(--sc-code-bg); + border: 1px solid var(--sc-code-border); + border-radius: 3px; + padding: 0 4px; + font-size: 12px; + color: var(--sc-code-text); + font-family: mononoki, monospace; +} + +.panel { + overflow-y: auto; + flex: 1; +} + +.section { + padding: 8px 0; +} + +.section + .section { + border-top: 1px solid var(--sc-border-subtle); +} + +.section-title { + padding: 4px 16px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--sc-text-muted); + font-family: Roboto, sans-serif; +} + +.section-title .count { + background: var(--sc-count-bg); + border-radius: 10px; + padding: 0 6px; + font-size: 10px; + margin-left: 4px; +} + +.helper-item, +.result-item { + display: flex; + align-items: center; + padding: 7px 16px; + cursor: pointer; + font-size: 13px; + gap: 8px; + color: var(--sc-text); + font-family: mononoki, monospace; +} + +.helper-item:hover, +.result-item:hover, +.helper-item.selected, +.result-item.selected { + background: var(--sc-bg-hover); +} + +.helper-item b { + color: var(--sc-text-accent); + min-width: 120px; +} + +.helper-item .hint { + color: var(--sc-text-hint); + font-size: 12px; +} + +.result-flags { + font-size: 16px; + min-width: 24px; +} + +.result-name { + flex: 1; +} + +.result-version { + color: var(--sc-text-version); + font-size: 12px; +} + +.flag-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + padding: 4px 16px 12px; +} + +.flag-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 5px; + cursor: pointer; + border: 1px solid var(--sc-border); + background: var(--sc-flag-bg); + color: var(--sc-text-muted); + transition: border-color 0.12s, background 0.12s, color 0.12s; + min-width: 0; +} + +.flag-chip:hover { + border-color: var(--sc-text-muted); + color: var(--sc-text); +} + +.flag-chip.flag-active { + background: var(--sc-flag-active-bg); + border-color: var(--sc-flag-active-border); + color: var(--sc-flag-active-text); +} + +.flag-emoji { + font-size: 14px; + flex-shrink: 0; +} + +.flag-name { + font-size: 11px; + font-family: mononoki, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.flag-count { + font-size: 10px; + background: var(--sc-flag-count-bg); + border-radius: 10px; + padding: 1px 5px; + flex-shrink: 0; + color: var(--sc-flag-count-text); +} + +.flag-chip.flag-active .flag-count { + background: var(--sc-flag-active-count-bg); + color: var(--sc-flag-active-count-text); +} + +.range-panel { + padding: 4px 16px 12px; +} + +.range-presets { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.range-preset { + padding: 5px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-family: mononoki, monospace; + background: var(--sc-bg-item); + border: 1px solid var(--sc-border); + color: var(--sc-text-muted); + transition: border-color 0.12s, color 0.12s; +} + +.range-preset:hover { + border-color: var(--sc-text-muted); + color: var(--sc-text); +} + +.range-hint { + font-size: 11px; + color: var(--sc-text-hint); + font-family: mononoki, monospace; +} + +.list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 16px; + cursor: pointer; + font-size: 13px; + font-family: mononoki, monospace; + color: var(--sc-text-muted); +} + +.list-item:hover, +.list-item.selected { + background: var(--sc-bg-hover); + color: var(--sc-text); +} + +.list-count { + font-size: 10px; + color: var(--sc-text-hint); + background: var(--sc-bg-item); + border-radius: 10px; + padding: 1px 6px; + flex-shrink: 0; +} + +.empty-state { + padding: 24px; + text-align: center; + color: var(--sc-text-muted); + font-size: 13px; + font-family: Roboto, sans-serif; +} + +.search-footer { + display: flex; + gap: 16px; + padding: 8px 16px; + border-top: 1px solid var(--sc-border); + font-size: 11px; + color: var(--sc-text-muted); + font-family: Roboto, sans-serif; + flex-shrink: 0; +} + +kbd { + background: var(--sc-kbd-bg); + border-radius: 3px; + padding: 1px 5px; + font-family: inherit; + font-size: 11px; + color: var(--sc-kbd-text); +} +` +]; diff --git a/public/components/search-command/search-command.js b/public/components/search-command/search-command.js new file mode 100644 index 00000000..3ca97266 --- /dev/null +++ b/public/components/search-command/search-command.js @@ -0,0 +1,487 @@ +// Import Third-party Dependencies +import { LitElement, html, nothing } from "lit"; +import { repeat } from "lit/directives/repeat.js"; + +// Import Internal Dependencies +import { currentLang, vec2Distance } from "../../common/utils.js"; +import { EVENTS } from "../../core/events.js"; +import { + FILTERS_NAME, + FILTER_HAS_HELPERS, + FILTER_MULTI_SELECT, + computeMatches, + getHelperValues +} from "./filters.js"; +import { searchCommandStyles } from "./search-command-styles.js"; +import { + renderFlagPanel, + renderRangePanel, + renderListPanel, + renderFilterList, + renderResults +} from "./search-command-panels.js"; +import "./search-chip.js"; + +class SearchCommand extends LitElement { + #linker = null; + #network = null; + #packages = []; + + static styles = searchCommandStyles; + + static properties = { + open: { type: Boolean }, + inputValue: { type: String }, + activeFilter: { type: String }, + queries: { type: Array }, + selectedIndex: { type: Number }, + results: { type: Array } + }; + + #handleKeydown = (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === "k") { + event.preventDefault(); + if (this.open) { + this.#close(); + } + else { + this.#openModal(); + } + + return; + } + + if (event.key === "Escape" && this.open) { + this.#close(); + } + }; + + #init = ({ detail: { linker, packages, network } }) => { + this.#linker = linker; + this.#network = network; + this.#packages = packages.map(({ id, name, version, flags }) => { + return { + id: String(id), + name, + version, + flags + }; + }); + }; + + constructor() { + super(); + this.open = false; + this.inputValue = ""; + this.activeFilter = null; + this.queries = []; + this.selectedIndex = -1; + this.results = []; + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("keydown", this.#handleKeydown); + window.addEventListener(EVENTS.SEARCH_COMMAND_INIT, this.#init); + } + + disconnectedCallback() { + document.removeEventListener("keydown", this.#handleKeydown); + window.removeEventListener(EVENTS.SEARCH_COMMAND_INIT, this.#init); + super.disconnectedCallback(); + } + + updated(changedProperties) { + if (changedProperties.has("open") && this.open) { + this.shadowRoot.querySelector("#cmd-input")?.focus(); + } + if (changedProperties.has("selectedIndex") && this.selectedIndex >= 0) { + this.shadowRoot.querySelector(".selected")?.scrollIntoView({ block: "nearest" }); + } + } + + #isNetworkViewActive() { + return document.getElementById("network--view").classList.contains("hidden") === false; + } + + #openModal() { + if (this.#linker === null || !this.#isNetworkViewActive()) { + return; + } + + this.open = true; + } + + #close() { + this.open = false; + this.inputValue = ""; + this.activeFilter = null; + this.queries = []; + this.selectedIndex = -1; + this.results = []; + } + + #getCurrentMatchingIds() { + if (this.queries.length === 0) { + return null; + } + + let ids = null; + for (const { filter, value } of this.queries) { + const matches = computeMatches(this.#linker, filter, value); + ids = ids === null ? matches : new Set([...ids].filter((id) => matches.has(id))); + } + + return ids; + } + + #computeLiveResults(filter, text) { + const freshMatches = computeMatches(this.#linker, filter, text); + const constraint = this.#getCurrentMatchingIds(); + const matchingIds = constraint === null + ? freshMatches + : new Set([...freshMatches].filter((id) => constraint.has(id))); + + this.results = this.#packages.filter((pkg) => matchingIds.has(pkg.id)); + } + + #addQuery(filter, value) { + const freshMatches = computeMatches(this.#linker, filter, value); + const constraint = this.#getCurrentMatchingIds(); + const matchingIds = constraint === null + ? freshMatches + : new Set([...freshMatches].filter((id) => constraint.has(id))); + + this.queries = [...this.queries, { filter, value, matchingIds }]; + this.results = this.#packages.filter((pkg) => matchingIds.has(pkg.id)); + this.inputValue = ""; + this.selectedIndex = -1; + + if (!FILTER_MULTI_SELECT.has(filter)) { + this.activeFilter = null; + } + } + + #removeQuery(query) { + this.queries = this.queries.filter((existing) => existing !== query); + const constraint = this.#getCurrentMatchingIds(); + this.results = constraint === null + ? [] + : this.#packages.filter((pkg) => constraint.has(pkg.id)); + } + + #removeQueryByValue(filter, value) { + this.queries = this.queries.filter((query) => !(query.filter === filter && query.value === value)); + const constraint = this.#getCurrentMatchingIds(); + this.results = constraint === null + ? [] + : this.#packages.filter((pkg) => constraint.has(pkg.id)); + } + + #removeLastQuery() { + const last = this.queries[this.queries.length - 1]; + this.queries = this.queries.slice(0, -1); + this.inputValue = `${last.filter}:${last.value}`; + this.activeFilter = last.filter; + this.selectedIndex = -1; + + const constraint = this.#getCurrentMatchingIds(); + if (constraint === null) { + this.#computeLiveResults(last.filter, last.value); + } + else { + this.results = this.#packages.filter((pkg) => constraint.has(pkg.id)); + } + + this.updateComplete.then(() => { + this.shadowRoot.querySelector("#cmd-input")?.focus(); + }); + } + + #onInput(event) { + const value = event.target.value; + this.inputValue = value; + this.selectedIndex = -1; + + const colonIndex = value.indexOf(":"); + if (colonIndex > 0) { + const potentialFilter = value.slice(0, colonIndex); + + if (FILTERS_NAME.has(potentialFilter)) { + this.activeFilter = potentialFilter; + const text = value.slice(colonIndex + 1).trim(); + + if (text.length > 0) { + this.#computeLiveResults(potentialFilter, text); + } + else { + this.results = []; + } + + return; + } + } + + this.activeFilter = null; + + const trimmed = value.trim(); + if (trimmed.length > 0) { + this.#computeLiveResults("package", trimmed); + } + else { + this.results = []; + } + } + + #onKeydown(event) { + const helpers = this.#visibleHelpers; + const total = helpers.length + this.results.length; + + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + this.selectedIndex = this.selectedIndex < total - 1 ? this.selectedIndex + 1 : 0; + break; + } + case "ArrowUp": { + event.preventDefault(); + this.selectedIndex = this.selectedIndex > 0 ? this.selectedIndex - 1 : total - 1; + break; + } + case "Enter": { + event.preventDefault(); + if (this.selectedIndex >= 0) { + this.#selectByIndex(this.selectedIndex); + } + else { + this.#validateCurrentInput(); + } + break; + } + case "Backspace": { + if (this.inputValue === "" && this.queries.length > 0) { + this.#removeLastQuery(); + } + break; + } + } + } + + #selectByIndex(index) { + const helpers = this.#visibleHelpers; + if (index < helpers.length) { + this.#selectHelper(helpers[index]); + } + else { + const result = this.results[index - helpers.length]; + this.#focusPackage(result.id); + } + } + + #selectHelper(helper) { + if (helper.type === "filter") { + this.inputValue = `${helper.value}:`; + this.activeFilter = helper.value; + this.selectedIndex = -1; + this.results = []; + } + else { + this.#addQuery(this.activeFilter, helper.value); + } + + this.updateComplete.then(() => { + this.shadowRoot.querySelector("#cmd-input")?.focus(); + }); + } + + #validateCurrentInput() { + if (this.activeFilter !== null) { + const text = this.inputValue.slice(this.activeFilter.length + 1).trim(); + if (text.length > 0) { + this.#addQuery(this.activeFilter, text); + } + + return; + } + + if (this.inputValue.trim().length > 0) { + this.#addQuery("package", this.inputValue.trim()); + + return; + } + + if (this.results.length > 0) { + this.#focusMultiplePackages(this.results.map((result) => Number(result.id))); + } + } + + #focusPackage(id) { + this.#network.focusNodeById(id); + window.navigation.setNavByName("network--view"); + this.#close(); + } + + #focusMultiplePackages(nodeIds) { + window.navigation.setNavByName("network--view"); + this.#network.highlightMultipleNodes(nodeIds); + window.locker.lock(); + + const currentSelectedNode = window.networkNav.currentNodeParams; + const shouldMove = !currentSelectedNode || !nodeIds.includes(currentSelectedNode.nodes[0]); + if (shouldMove) { + const origin = this.#network.network.getViewPosition(); + const closestNode = nodeIds + .map((id) => { + return { id, pos: this.#network.network.getPosition(id) }; + }) + .reduce((nodeA, nodeB) => (vec2Distance(origin, nodeA.pos) < vec2Distance(origin, nodeB.pos) ? nodeA : nodeB)); + + const scale = nodeIds.length > 3 ? 0.25 : 0.35; + this.#network.network.focus(closestNode.id, { animation: true, scale }); + } + + this.#close(); + } + + get #visibleHelpers() { + if (this.activeFilter === null) { + const text = this.inputValue.toLowerCase(); + + const filterHints = window.i18n[currentLang()].search_command.filter_hints; + + return [...FILTERS_NAME] + .filter((filterName) => text === "" || filterName.startsWith(text)) + .map((filterName) => { + return { value: filterName, display: `${filterName}:`, hint: filterHints[filterName], type: "filter" }; + }); + } + + if (!FILTER_HAS_HELPERS.has(this.activeFilter)) { + return []; + } + + const searchText = this.inputValue.slice(this.activeFilter.length + 1).toLowerCase(); + const allValues = getHelperValues(this.#linker, this.activeFilter); + + return allValues + .filter((helper) => searchText === "" || helper.display.toLowerCase().includes(searchText)) + .map((helper) => { + return { ...helper, type: "value" }; + }); + } + + #renderActiveFilterPanel(helpers) { + const panelProps = { + linker: this.#linker, + queries: this.queries, + inputValue: this.inputValue, + activeFilter: this.activeFilter, + selectedIndex: this.selectedIndex, + onAdd: (filter, value) => this.#addQuery(filter, value), + onRemove: (filter, value) => this.#removeQueryByValue(filter, value) + }; + + switch (this.activeFilter) { + case "flag": + return renderFlagPanel(panelProps); + case "size": + case "version": + return renderRangePanel(panelProps); + default: + return helpers.length > 0 ? renderListPanel({ ...panelProps, helpers }) : nothing; + } + } + + render() { + if (!this.open) { + return nothing; + } + + const i18n = window.i18n[currentLang()].search_command; + const helpers = this.#visibleHelpers; + const isPanelMode = this.activeFilter !== null; + const isEmpty = helpers.length === 0 && this.results.length === 0 && this.inputValue.length > 0; + const showRichPlaceholder = this.inputValue === "" && this.queries.length === 0; + const showRefinePlaceholder = this.inputValue === "" && this.queries.length > 0; + const helperPanel = helpers.length > 0 + ? renderFilterList({ + helpers, + selectedIndex: this.selectedIndex, + onSelect: (helper) => this.#selectHelper(helper) + }) + : nothing; + + return html` +
+ +
+ `; + } +} + +customElements.define("search-command", SearchCommand); diff --git a/public/components/searchbar/searchbar.css b/public/components/searchbar/searchbar.css index 25317c07..5d19bbf3 100644 --- a/public/components/searchbar/searchbar.css +++ b/public/components/searchbar/searchbar.css @@ -1,200 +1,3 @@ -#searchbar { - background: linear-gradient(to bottom, #37474f 0%, #263238 100%); - display: flex; - height: inherit; - box-sizing: border-box; - border-left: 2px solid #0f041a; -} - -#searchbar>* { - transform: skewX(20deg); -} - -#searchbar>div.search-items { - padding-left: 5px; - display: flex; - align-items: center; -} - -#searchbar>div.search-items div.cancel { - cursor: pointer; - width: 14px; - height: 14px; - display: flex; - justify-content: center; - align-items: center; - border-radius: 2px; - font-size: 14px; - font-family: roboto; - color: #B3E5FC; - font-weight: bold; -} - -#searchbar>div.search-items div:not(.cancel) { - padding: 5px; - background: #37474F; - border-radius: 3px; - font-size: 13px; - font-family: mononoki; - display: flex; -} - -#searchbar>div.search-items div p { - margin-left: 5px; -} - -#searchbar>div.search-items div+div { - margin-left: 5px; -} - -#searchbar>input { - width: 210px; - background: none; - border: none; - outline: none; - color: #FFF; - font-family: mononoki; - margin-bottom: 2px; -} - -#searchbar>input::placeholder { - color: #FFF; -} - -#searchbar>i { - width: 45px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 22px; -} - -div.search-result-background { - position: absolute; - display: none; - margin-top: 35px; - right: -130px; - min-width: 360px; - max-width: 400px; - padding: 10px !important; - background: #263238; - box-shadow: 1px 1px 10px rgb(20 20 20 / 40%); - border-radius: 4px; - box-sizing: border-box; - flex-direction: column; -} - -div.search-result-background.show { - display: flex; -} - -div.search-result-background>div.search-result-pannel { - display: flex; - flex-direction: column; - max-height: 70vh; - overflow-y: auto; -} - -div.search-result-pannel>.helpers { - display: flex; - flex-direction: column; - border-bottom: 1px solid #37474F; - padding-bottom: 10px; - margin-bottom: 10px; - font-family: mononoki; -} - -div.search-result-pannel>.helpers.hide { - display: none; -} - -div.search-result-pannel>.helpers>.title { - display: flex; - align-items: center; - font-size: 14px; - font-weight: bold; - color: #ECEFF1; - margin-bottom: 10px; -} - -div.search-result-pannel>.helpers>.title>a { - margin-left: auto; - color: inherit; -} - -div.search-result-pannel>.helpers>.line { - display: flex; - align-items: center; - font-size: 13px; - border-radius: 4px; - padding: 2px 10px; - min-height: 20px; -} - -div.search-result-pannel>.helpers>.line.hide { - display: none; -} - -div.search-result-pannel>.helpers>.line+.line { - margin-top: 2px; -} - -div.search-result-pannel>.helpers>.line:hover { - cursor: pointer; - background: var(--secondary-darker); -} - -div.search-result-pannel>.helpers>.line:hover p { - color: #FFF; -} - -div.search-result-pannel>.helpers>.line b { - margin-right: 5px; - color: #E1F5FE; -} - -div.search-result-pannel>.helpers>.line p { - color: #B0BEC5; -} - -div.search-result-pannel .package { - background: rgb(69 90 100 / 19.8%); - align-items: center; - border-radius: 2px; - padding: 10px; - display: flex; - flex-shrink: 0; - text-shadow: 1px 1px 1px rgb(20 20 20 / 50%); - font-size: 14px; - letter-spacing: 0.7px; - margin-right: 2px; -} - -div.search-result-pannel .package:hover { - color: #E1F5FE; - background: #01579B; - cursor: pointer; -} - -div.search-result-pannel .package>p { - margin-right: 10px; -} - -div.search-result-pannel .package>b { - color: #ffeb3b; - font-weight: bold; - margin-left: auto; -} - -div.search-result-pannel .package.hide { - display: none !important; -} - -div.search-result-pannel .package+.package { - margin-top: 5px; -} - #search-nav { z-index: 30; display: flex; @@ -215,19 +18,6 @@ body.dark #search-nav { background: var(--dark-theme-primary-color); } -#search-nav:has(#searchbar[style*="display: none;"]) { - display: none; -} - -#search-nav .search-result-pannel .package { - height: 30px; - color: rgb(229 229 229); - display: flex; - align-items: center; - padding: 0 10px; - font-family: Roboto; -} - #search-nav .packages { height: inherit; display: flex; @@ -285,7 +75,7 @@ body.dark #search-nav .packages>.package:not(.active):hover { background: #f57c00; } -#search-nav .packages>.package>b{ +#search-nav .packages>.package>b { font-weight: bold; font-size: 12px; margin-left: 5px; diff --git a/public/components/searchbar/searchbar.html b/public/components/searchbar/searchbar.html deleted file mode 100644 index 406cd3a4..00000000 --- a/public/components/searchbar/searchbar.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - diff --git a/public/components/searchbar/searchbar.js b/public/components/searchbar/searchbar.js deleted file mode 100644 index bf5f8db4..00000000 --- a/public/components/searchbar/searchbar.js +++ /dev/null @@ -1,575 +0,0 @@ -// Import Third-party Dependencies -import semver from "semver"; -import sizeSatisfies from "@nodesecure/size-satisfies"; - -// Import Internal Dependencies -import { createDOMElement, vec2Distance, currentLang } from "../../common/utils.js"; - -// CONSTANTS -const kFiltersName = new Set(["package", "version", "flag", "license", "author", "ext", "builtin", "size"]); -const kHelpersTitleName = { - ext: "File extensions", - builtin: "Node.js core modules", - license: "Available licenses", - flag: "Available flags" -}; -const kFocusHelperBgColor = "#1976d2"; -const kHelpersTemplateName = { - flag: "search_helpers_flags", - license(linker) { - const fragment = document.createDocumentFragment(); - const items = new Set(); - - for (const { uniqueLicenseIds = [] } of linker.values()) { - uniqueLicenseIds.forEach((ext) => items.add(ext)); - } - [...items].forEach((value) => fragment.appendChild(createLineElement(value))); - - return fragment; - }, - ext(linker) { - const fragment = document.createDocumentFragment(); - const items = new Set(); - for (const { composition } of linker.values()) { - composition.extensions.forEach((ext) => items.add(ext)); - } - items.delete(""); - [...items].forEach((value) => fragment.appendChild(createLineElement(value))); - - return fragment; - }, - builtin(linker) { - const fragment = document.createDocumentFragment(); - const items = new Set(); - for (const { composition } of linker.values()) { - composition.required_nodejs.forEach((ext) => items.add(ext)); - } - [...items].forEach((value) => fragment.appendChild(createLineElement(value))); - - return fragment; - }, - author(linker) { - const fragment = document.createDocumentFragment(); - const items = new Set(); - for (const { author } of linker.values()) { - if (author === null) { - continue; - } - items.add(author.name); - } - [...items].forEach((value) => fragment.appendChild(createLineElement(value))); - - return fragment; - } -}; - -function createLineElement(text) { - const pElement = createDOMElement("p", { text }); - - return createDOMElement("div", { - classList: ["line"], - childs: [pElement], - attributes: { "data-value": text } - }); -} - -export class SearchBar { - constructor(network, linker) { - this.container = document.getElementById("searchbar"); - this.background = document.querySelector(".search-result-background"); - this.helper = document.querySelector(".search-result-pannel > .helpers"); - this.input = document.getElementById("search-bar-input"); - this.itemsContainer = this.container.querySelector(".search-items"); - this.allSearchPackages = document.querySelectorAll(".search-result-pannel > .package"); - - this.network = network; - this.linker = linker; - - this.delayOpenSearchBar = true; - this.activeQuery = new Set(); - this.queries = []; - this.inputUpdateValue = false; - this.forceNewItem = false; - let confirmBackspace = false; - let currentActiveQueryName = null; - let helperSelectionIndex = null; - - this.container.addEventListener("click", () => this.open()); - - this.input.addEventListener("keyup", (event) => { - const shownPackages = document.querySelectorAll(".search-result-pannel > .package:not(.hide)"); - const shownHelpers = document.querySelectorAll(".helpers > .line:not(.hide)"); - const shownItems = [...shownHelpers, ...shownPackages]; - - if (event.key === "ArrowDown") { - if (helperSelectionIndex === null) { - helperSelectionIndex = 0; - } - else { - helperSelectionIndex++; - if (helperSelectionIndex >= shownItems.length) { - helperSelectionIndex = 0; - } - } - - shownItems.forEach((line, index) => { - line.style.backgroundColor = "initial"; - - if (index === helperSelectionIndex) { - line.style.backgroundColor = kFocusHelperBgColor; - } - }); - - return; - } - else if (event.key === "ArrowUp") { - if (helperSelectionIndex === null) { - helperSelectionIndex = shownItems.length - 1; - } - else { - helperSelectionIndex--; - if (helperSelectionIndex < 0) { - helperSelectionIndex = shownItems.length - 1; - } - } - - shownItems.forEach((line, index) => { - line.style.backgroundColor = "initial"; - - if (index === helperSelectionIndex) { - line.style.backgroundColor = kFocusHelperBgColor; - } - }); - - return; - } - else if (event.key !== "Enter") { - helperSelectionIndex = null; - } - if ((this.input.value === null || this.input.value === "")) { - if (event.key === "Enter" && helperSelectionIndex !== null) { - const line = shownItems[helperSelectionIndex]; - if (line.classList.contains("package")) { - helperSelectionIndex = null; - this.close(); - this.focusNodeById(line.getAttribute("data-value")); - - return; - } - this.input.value = line.getAttribute("data-value"); - - const [keyword] = this.input.value.split(":"); - if (kFiltersName.has(keyword)) { - currentActiveQueryName = keyword; - this.showPannelHelper(currentActiveQueryName); - helperSelectionIndex = null; - } - - return; - } - - // we always want to show the default helpers when the input is empty! - this.showPannelHelper(); - - // hide all results if there is no active queries! - if (this.activeQuery.size === 0) { - this.allSearchPackages.forEach((element) => element.classList.add("hide")); - } - // if backspace is received and that we have active queries - // then we want the query to be re-inserted in the input field! - else if (event.key === "Backspace") { - if (confirmBackspace) { - this.removeSearchBarItem(); - currentActiveQueryName = null; - } - else { - confirmBackspace = true; - } - } - else if (event.key === "Enter") { - const nodeIds = []; - - for (const pkgElement of this.allSearchPackages) { - if (!pkgElement.classList.contains("hide")) { - nodeIds.push(Number(pkgElement.getAttribute("data-value"))); - } - } - - this.focusMultipleNodeIds(nodeIds); - } - - return; - } - confirmBackspace = false; - - // if there is no active query filter name, then we want to filter the helper - if (currentActiveQueryName === null) { - this.showHelperByInputText(); - } - - // inputUpdateValue is used to force a re-hydratation of the input - if (event.key === ":" || this.inputUpdateValue) { - if (this.inputUpdateValue) { - setTimeout(() => (this.inputUpdateValue = false), 1); - } - - // here .split is important because we may have to re-proceed complete search string like 'filter: text'. - const [keyword] = this.input.value.split(":"); - if (kFiltersName.has(keyword)) { - currentActiveQueryName = keyword; - this.showPannelHelper(currentActiveQueryName); - } - - // TODO: we may want to implement a else here to generate an invalid keyword error! - } - - // In case there is no active package query and the enter key is pressed, then: - // - we fallback the current query to "package". - let isPackageSearch = false; - if (!this.activeQuery.has("package") && currentActiveQueryName === null && event.key === "Enter") { - if (this.input.value.trim() === "") { - return; - } - - currentActiveQueryName = "package"; - isPackageSearch = true; - this.showPannelHelper(); - } - else if (currentActiveQueryName === null) { - return; - } - - if (event.key === "Enter" && helperSelectionIndex !== null) { - const line = shownItems[helperSelectionIndex]; - helperSelectionIndex = null; - if (line.classList.contains("package")) { - line.style.backgroundColor = "initial"; - this.close(); - this.focusNodeById(line.getAttribute("data-value")); - } - else { - if (line.getAttribute("data-value").startsWith(this.input.value)) { - this.input.value = line.getAttribute("data-value"); - const [keyword] = this.input.value.split(":"); - currentActiveQueryName = keyword; - this.showPannelHelper(currentActiveQueryName); - - return; - } - - this.input.value += line.getAttribute("data-value"); - - this.helper.classList.add("hide"); - } - } - // fetch the search text - const text = isPackageSearch ? - this.input.value.trim() : - this.input.value.slice(currentActiveQueryName.length + 1).trim(); - - this.showHelperByInputText(text); - - if (text.length === 0) { - return; - } - - // fetch matching result ids! - const matchingIds = this.computeText(currentActiveQueryName, text); - - if (event.key === "Enter" || this.forceNewItem) { - if (this.forceNewItem) { - setTimeout(() => (this.forceNewItem = false), 1); - } - - this.appendCancelButton(); - this.addSearchBarItem(currentActiveQueryName, text, matchingIds); - this.showPannelHelper(); - currentActiveQueryName = null; - this.input.value = ""; - helperSelectionIndex = null; - } - - this.showResultsByIds(matchingIds); - }); - - document.addEventListener("click", (event) => { - if (!this.container.contains(event.target) - && this.background.classList.contains("show") - && !this.inputUpdateValue && !this.forceNewItem) { - helperSelectionIndex = null; - this.close(); - } - }); - - this.showPannelHelper(); - const self = this; - for (const domElement of this.allSearchPackages) { - domElement.addEventListener("click", function clikEvent() { - helperSelectionIndex = null; - self.focusNodeById(this.getAttribute("data-value")); - }); - } - - if (window.navigation.getAnchor() !== "network--view") { - this.container.style.display = "none"; - } - } - - addNewSearchText(filterName, searchedValue) { - const matchingIds = this.computeText(filterName, searchedValue); - - this.showResultsByIds(matchingIds); - - this.appendCancelButton(); - this.addSearchBarItem(filterName, searchedValue, matchingIds); - this.showPannelHelper(); - - this.open(); - } - - appendCancelButton() { - if (this.activeQuery.size > 0) { - return; - } - - const divElement = createDOMElement("div", { classList: ["cancel"], text: "x" }); - divElement.addEventListener("click", () => { - this.close(); - setTimeout(() => this.input.focus(), 5); - }); - - this.itemsContainer.appendChild(divElement); - } - - showPannelHelper(filterName = null) { - if (filterName !== null && !Reflect.has(kHelpersTemplateName, filterName)) { - this.helper.classList.add("hide"); - - return; - } - - document.querySelectorAll(".helpers > .line").forEach((element) => element.classList.add("hide")); - this.helper.classList.remove("hide"); - const templateName = filterName === null ? "search_helpers_default" : kHelpersTemplateName[filterName]; - - let clone; - if (typeof templateName === "function") { - clone = templateName(this.linker); - } - else { - const templateElement = document.getElementById(templateName); - clone = templateElement.content.cloneNode(true); - } - - clone.querySelectorAll(".line").forEach((element) => { - element.addEventListener("click", () => { - if (filterName === null) { - this.inputUpdateValue = true; - this.input.value = element.getAttribute("data-value"); - } - else { - this.forceNewItem = true; - this.input.value += element.getAttribute("data-value"); - this.helper.classList.add("hide"); - } - - this.input.focus(); - this.input.dispatchEvent(new Event("keyup")); - }); - }); - - const titleText = window.i18n[currentLang()].search[ - Reflect.has(kHelpersTitleName, filterName) ? kHelpersTitleName[filterName] : "default" - ]; - // eslint-disable-next-line @stylistic/max-len - this.helper.innerHTML = `

${titleText}

`; - this.helper.appendChild(clone); - } - - removeSearchBarItem() { - const [fullQuery, divElement] = this.queries.pop(); - const [filterName] = fullQuery.split(":"); - this.activeQuery.delete(filterName); - this.itemsContainer.removeChild(divElement); - if (this.activeQuery.size === 0) { - while (this.itemsContainer.firstChild) { - this.itemsContainer.removeChild(this.itemsContainer.lastChild); - } - } - - this.input.value = fullQuery; - this.inputUpdateValue = true; - this.input.focus(); - this.input.dispatchEvent(new Event("keyup")); - } - - addSearchBarItem(filterName, text, ids) { - const bElement = createDOMElement("b", { text: `${filterName}:` }); - const pElement = createDOMElement("p", { text }); - - const element = this.itemsContainer.children[this.itemsContainer.children.length - 1]; - const divElement = createDOMElement("div", { childs: [bElement, pElement] }); - this.itemsContainer.insertBefore(divElement, element); - - this.activeQuery.add(filterName); - this.queries.push([`${filterName}:${text}`, divElement, [...ids]]); - } - - computeText(filterName, inputValue) { - const matchingIds = new Set(); - const storedIds = new Set(); - for (const [, , ids] of this.queries) { - ids.forEach((id) => storedIds.add(id)); - } - - for (const [id, opt] of this.linker) { - switch (filterName) { - case "version": { - if (semver.satisfies(opt.version, inputValue)) { - matchingIds.add(String(id)); - } - break; - } - case "package": { - if (new RegExp(inputValue, "gi").test(opt.name)) { - matchingIds.add(String(id)); - } - break; - } - case "license": { - const hasMatchingLicense = opt.uniqueLicenseIds.some( - (value) => new RegExp(inputValue, "gi").test(value) - ); - if (hasMatchingLicense) { - matchingIds.add(String(id)); - } - - break; - } - case "ext": { - const extensions = new Set(opt.composition.extensions); - const wantedExtension = inputValue.startsWith(".") ? inputValue : `.${inputValue}`; - if (extensions.has(wantedExtension.toLowerCase())) { - matchingIds.add(String(id)); - } - - break; - } - case "size": { - if (sizeSatisfies(inputValue, opt.size)) { - matchingIds.add(String(id)); - } - - break; - } - case "builtin": { - const hasMatchingBuiltin = opt.composition.required_nodejs - .some((value) => new RegExp(inputValue, "gi").test(value)); - if (hasMatchingBuiltin) { - matchingIds.add(String(id)); - } - - break; - } - case "author": { - const authorRegex = new RegExp(inputValue, "gi"); - - if ( - (typeof opt.author === "string" && authorRegex.test(opt.author)) || - (opt.author !== null && "name" in opt.author && authorRegex.test(opt.author.name)) - ) { - matchingIds.add(String(id)); - } - break; - } - case "flag": - if (opt.flags.includes(inputValue)) { - matchingIds.add(String(id)); - } - break; - } - } - - return storedIds.size === 0 ? matchingIds : new Set([...matchingIds].filter((value) => storedIds.has(value))); - } - - focusNodeById(nodeId) { - window.navigation.setNavByName("network--view"); - this.delayOpenSearchBar = false; - this.network.focusNodeById(nodeId); - this.close(); - - setTimeout(() => { - this.delayOpenSearchBar = true; - }, 5); - } - - focusMultipleNodeIds(nodeIds) { - window.navigation.setNavByName("network--view"); - this.delayOpenSearchBar = false; - - this.network.highlightMultipleNodes(nodeIds); - window.locker.lock(); - - const currentSelectedNode = window.networkNav.currentNodeParams; - const moveTo = !currentSelectedNode || !nodeIds.includes(currentSelectedNode.nodes[0]); - if (moveTo) { - const origin = this.network.network.getViewPosition(); - const closestNode = nodeIds - .map((id) => { - return { id, pos: this.network.network.getPosition(id) }; - }) - .reduce( - (a, b) => (vec2Distance(origin, a.pos) < vec2Distance(origin, b.pos) ? a : b) - ); - - const scale = nodeIds.length > 3 ? 0.25 : 0.35; - this.network.network.focus(closestNode.id, { - animation: true, - scale - }); - } - - this.close(); - - setTimeout(() => { - this.delayOpenSearchBar = true; - }, 5); - } - - showResultsByIds(ids = new Set()) { - for (const pkgElement of this.allSearchPackages) { - const isMatching = ids.has(pkgElement.getAttribute("data-value")); - pkgElement.classList[isMatching ? "remove" : "add"]("hide"); - } - } - - showHelperByInputText(text = this.input.value.trim()) { - const elements = document.querySelectorAll(".helpers > .line"); - - for (const pkgElement of elements) { - const dataValue = pkgElement.getAttribute("data-value"); - pkgElement.classList[dataValue.match(text) ? "remove" : "add"]("hide"); - } - } - - open() { - if (!this.background.classList.contains("show") && this.delayOpenSearchBar) { - this.helper.classList.remove("hide"); - this.background.classList.toggle("show"); - } - } - - close() { - this.background.classList.remove("show"); - this.allSearchPackages.forEach((element) => element.classList.add("hide")); - - this.itemsContainer.innerHTML = ""; - this.activeQuery = new Set(); - this.queries = []; - this.input.value = ""; - this.input.blur(); - this.helper.classList.remove("hide"); - this.showPannelHelper(); - } -} diff --git a/public/components/wiki/wiki.js b/public/components/wiki/wiki.js index a6b54c5e..acce0106 100644 --- a/public/components/wiki/wiki.js +++ b/public/components/wiki/wiki.js @@ -58,7 +58,8 @@ export class Wiki { #keydownHotkeys(event) { const isTargetInput = event.target.tagName === "INPUT"; const isTargetPopup = event.target.id === "popup--background"; - if (isTargetInput || isTargetPopup) { + const isSearchCommandOpen = Boolean(document.querySelector("search-command")?.open); + if (isTargetInput || isTargetPopup || isSearchCommandOpen) { return; } diff --git a/public/core/events.js b/public/core/events.js index cffbfb4d..43b5e71e 100644 --- a/public/core/events.js +++ b/public/core/events.js @@ -9,5 +9,6 @@ export const EVENTS = { MODAL_CLOSED: "modal-closed", MODAL_OPENED: "modal-opened", NETWORK_VIEW_HID: "network-view-hid", - NETWORK_VIEW_SHOWED: "network-view-showed" + NETWORK_VIEW_SHOWED: "network-view-showed", + SEARCH_COMMAND_INIT: "search-command-init" }; diff --git a/public/core/network-navigation.js b/public/core/network-navigation.js index c2ef4f5a..978b0b08 100644 --- a/public/core/network-navigation.js +++ b/public/core/network-navigation.js @@ -131,7 +131,8 @@ export class NetworkNavigation { const isWikiOpen = document.getElementById("documentation-root-element").classList.contains("slide-in"); const isTargetPopup = event.target.id === "popup--background"; const isTargetInput = event.target.tagName === "INPUT"; - if (isNetworkViewHidden || isWikiOpen || isTargetPopup || isTargetInput) { + const isSearchCommandOpen = Boolean(document.querySelector("search-command")?.open); + if (isNetworkViewHidden || isWikiOpen || isTargetPopup || isTargetInput || isSearchCommandOpen) { return; } diff --git a/public/core/search-nav.js b/public/core/search-nav.js index 3db36515..c917dd4e 100644 --- a/public/core/search-nav.js +++ b/public/core/search-nav.js @@ -1,9 +1,11 @@ // Import Internal Dependencies import { createDOMElement, parseNpmSpec } from "../common/utils"; -import { SearchBar } from "../components/searchbar/searchbar"; + +// CONSTANTS +const kSearchShortcut = navigator.userAgent.includes("Mac") ? "⌘K" : "Ctrl+K"; export function initSearchNav(data, options) { - const { initFromZero = true, searchOptions = null } = options; + const { initFromZero = true } = options; const searchNavElement = document.getElementById("search-nav"); if (!searchNavElement) { @@ -17,28 +19,16 @@ export function initSearchNav(data, options) { ); } - if (searchOptions !== null) { - const { nsn, secureDataSet } = searchOptions; - - if (window.searchbar) { - console.log("[SEARCH-NAV] cleanup searchbar"); - document.getElementById("searchbar")?.remove(); - } - - const searchElement = document.getElementById("searchbar-content"); - searchNavElement.appendChild( - searchElement.content.cloneNode(true) + if (document.getElementById("search-shortcut-hint") === null) { + document.body.appendChild( + createDOMElement("div", { + classList: ["search-shortcut-hint"], + attributes: { id: "search-shortcut-hint" }, + childs: [ + createDOMElement("kbd", { text: kSearchShortcut }) + ] + }) ); - - const searchBarPackagesContainer = document.getElementById("package-list"); - for (const info of secureDataSet.packages) { - const content = `

${info.flags} ${info.name}

${info.version}`; - searchBarPackagesContainer.insertAdjacentHTML( - "beforeend", - `
${content}
` - ); - } - window.searchbar = new SearchBar(nsn, secureDataSet.linker); } } diff --git a/public/main.css b/public/main.css index c9f37642..80d1e2a0 100644 --- a/public/main.css +++ b/public/main.css @@ -92,3 +92,28 @@ main>section.content .view { .hidden { display: none !important; } + +#search-shortcut-hint { + position: fixed; + top: 8px; + right: 12px; + z-index: 50; + pointer-events: none; +} + +#search-shortcut-hint kbd { + background: rgb(0 0 0 / 8%); + border: 1px solid rgb(0 0 0 / 15%); + border-radius: 4px; + padding: 2px 8px; + font-size: 11px; + font-family: mononoki, monospace; + color: rgb(0 0 0 / 45%); + letter-spacing: 0.5px; +} + +body.dark #search-shortcut-hint kbd { + background: rgb(255 255 255 / 8%); + border-color: rgb(255 255 255 / 15%); + color: rgb(255 255 255 / 40%); +} diff --git a/public/main.js b/public/main.js index 6ad77cc1..ed29a878 100644 --- a/public/main.js +++ b/public/main.js @@ -9,6 +9,7 @@ import "./components/popup/popup.js"; import "./components/locker/locker.js"; import "./components/legend/legend.js"; import "./components/locked-navigation/locked-navigation.js"; +import "./components/search-command/search-command.js"; import { Settings } from "./components/views/settings/settings.js"; import { HomeView } from "./components/views/home/home.js"; import { SearchView } from "./components/views/search/search.js"; @@ -61,13 +62,8 @@ async function onSocketPayload(event) { window.activePackage = name + "@" + version; await init({ navigateToNetworkView: true }); - initSearchNav(payload, { - initFromZero: false, - searchOptions: { - nsn, - secureDataSet - } - }); + initSearchNav(payload, { initFromZero: false }); + dispatchSearchCommandInit(); } async function onSocketInitOrReload(event) { @@ -85,12 +81,8 @@ async function onSocketInitOrReload(event) { window.recentPackageCache ); - initSearchNav(cache, { - searchOptions: { - nsn, - secureDataSet - } - }); + initSearchNav(cache, {}); + dispatchSearchCommandInit(); searchview.mount(); searchview.initialize(); @@ -107,13 +99,23 @@ async function onSocketInitOrReload(event) { await init(); // FIXME: initSearchNav is called twice, we need to fix this - initSearchNav(cache, { - searchOptions: { - nsn, - secureDataSet - } - }); + initSearchNav(cache, {}); + dispatchSearchCommandInit(); + } +} + +function dispatchSearchCommandInit() { + if (!nsn || !secureDataSet) { + return; } + + window.dispatchEvent(new CustomEvent(EVENTS.SEARCH_COMMAND_INIT, { + detail: { + network: nsn, + linker: secureDataSet.linker, + packages: secureDataSet.packages + } + })); } async function init(options = {}) { diff --git a/test/ui/search-command-filters.test.js b/test/ui/search-command-filters.test.js new file mode 100644 index 00000000..71b79387 --- /dev/null +++ b/test/ui/search-command-filters.test.js @@ -0,0 +1,346 @@ +// Import Node.js Dependencies +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// Import Internal Dependencies +import { + computeMatches, + getFlagCounts, + getFilterValueCounts, + getHelperValues +} from "../../public/components/search-command/filters.js"; + +const kLinker = new Map([ + [0, { + name: "express", + version: "4.18.2", + flags: ["hasWarnings", "hasIndirectDependencies"], + uniqueLicenseIds: ["MIT"], + composition: { extensions: [".js", ".ts"], required_nodejs: ["fs", "path"] }, + author: { name: "TJ Holowaychuk" }, + size: 102_400 + }], + [1, { + name: "lodash", + version: "0.5.0", + flags: ["hasWarnings", "hasMinifiedCode"], + uniqueLicenseIds: ["MIT", "ISC"], + composition: { extensions: [".js", ""], required_nodejs: ["path"] }, + author: "John-David Dalton", + size: 5_000 + }], + [2, { + name: "semver", + version: "7.5.4", + flags: [], + uniqueLicenseIds: ["ISC"], + composition: { extensions: [".js"], required_nodejs: [] }, + author: null, + size: 20_000 + }] +]); + +describe("computeMatches", () => { + describe("filter: package", () => { + it("should match packages by regex against name", () => { + const result = computeMatches(kLinker, "package", "express"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should match multiple packages with a partial regex", () => { + const result = computeMatches(kLinker, "package", "e"); + + assert.deepEqual(result, new Set(["0", "2"])); + }); + + it("should return empty set on invalid regex", () => { + const result = computeMatches(kLinker, "package", "[invalid"); + + assert.deepEqual(result, new Set()); + }); + }); + + describe("filter: version", () => { + it("should match packages satisfying a semver range", () => { + const result = computeMatches(kLinker, "version", ">=1.0.0"); + + assert.deepEqual(result, new Set(["0", "2"])); + }); + + it("should match packages with exact version preset <1.0.0", () => { + const result = computeMatches(kLinker, "version", "<1.0.0"); + + assert.deepEqual(result, new Set(["1"])); + }); + + it("should return empty set on invalid semver range", () => { + const result = computeMatches(kLinker, "version", "not-a-semver"); + + assert.deepEqual(result, new Set()); + }); + }); + + describe("filter: flag", () => { + it("should match packages that have the given flag", () => { + const result = computeMatches(kLinker, "flag", "hasWarnings"); + + assert.deepEqual(result, new Set(["0", "1"])); + }); + + it("should match only packages with a specific flag", () => { + const result = computeMatches(kLinker, "flag", "hasMinifiedCode"); + + assert.deepEqual(result, new Set(["1"])); + }); + + it("should return empty set when no package has the flag", () => { + const result = computeMatches(kLinker, "flag", "isDeprecated"); + + assert.deepEqual(result, new Set()); + }); + }); + + describe("filter: license", () => { + it("should match packages by license regex", () => { + const result = computeMatches(kLinker, "license", "MIT"); + + assert.deepEqual(result, new Set(["0", "1"])); + }); + + it("should match packages with ISC license", () => { + const result = computeMatches(kLinker, "license", "ISC"); + + assert.deepEqual(result, new Set(["1", "2"])); + }); + + it("should return empty set on invalid license regex", () => { + const result = computeMatches(kLinker, "license", "[invalid"); + + assert.deepEqual(result, new Set()); + }); + }); + + describe("filter: ext", () => { + it("should match packages that have a given extension (with dot)", () => { + const result = computeMatches(kLinker, "ext", ".ts"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should match packages that have a given extension (without dot)", () => { + const result = computeMatches(kLinker, "ext", "ts"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should match all packages that have .js extension", () => { + const result = computeMatches(kLinker, "ext", ".js"); + + assert.deepEqual(result, new Set(["0", "1", "2"])); + }); + + it("should not match the empty-string extension", () => { + const result = computeMatches(kLinker, "ext", ""); + + assert.deepEqual(result, new Set()); + }); + }); + + describe("filter: builtin", () => { + it("should match packages using a given Node.js core module by regex", () => { + const result = computeMatches(kLinker, "builtin", "fs"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should match packages using path", () => { + const result = computeMatches(kLinker, "builtin", "path"); + + assert.deepEqual(result, new Set(["0", "1"])); + }); + + it("should return empty set when no package uses the module", () => { + const result = computeMatches(kLinker, "builtin", "crypto"); + + assert.deepEqual(result, new Set()); + }); + + it("should return empty set on invalid builtin regex", () => { + const result = computeMatches(kLinker, "builtin", "[invalid"); + + assert.deepEqual(result, new Set()); + }); + }); + + describe("filter: size", () => { + it("should match packages strictly above 100kb", () => { + // express: 102_400 bytes = 100kb, not >100kb + const result = computeMatches(kLinker, "size", ">100kb"); + + assert.deepEqual(result, new Set()); + }); + + it("should match packages at or above 100kb", () => { + // express: 102_400 bytes = 100kb, matches >=100kb + const result = computeMatches(kLinker, "size", ">=100kb"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should match packages below 10kb", () => { + const result = computeMatches(kLinker, "size", "<10kb"); + + assert.deepEqual(result, new Set(["1"])); + }); + + it("should match packages at or above 10kb", () => { + const result = computeMatches(kLinker, "size", ">=10kb"); + + assert.deepEqual(result, new Set(["0", "2"])); + }); + + it("should return empty set on invalid size expression", () => { + const result = computeMatches(kLinker, "size", "not-a-size"); + + assert.deepEqual(result, new Set()); + }); + }); + + describe("filter: author", () => { + it("should match packages whose author is an object with a matching name", () => { + const result = computeMatches(kLinker, "author", "TJ"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should match packages whose author is a plain string", () => { + const result = computeMatches(kLinker, "author", "Dalton"); + + assert.deepEqual(result, new Set(["1"])); + }); + + it("should not match packages with null author", () => { + const result = computeMatches(kLinker, "author", "semver"); + + assert.deepEqual(result, new Set()); + }); + + it("should return empty set on invalid author regex", () => { + const result = computeMatches(kLinker, "author", "[invalid"); + + assert.deepEqual(result, new Set()); + }); + }); +}); + +describe("getFlagCounts", () => { + it("should return correct counts per flag name", () => { + const result = getFlagCounts(kLinker); + + assert.deepEqual(result, new Map([ + ["hasWarnings", 2], + ["hasIndirectDependencies", 1], + ["hasMinifiedCode", 1] + ])); + }); + + it("should return an empty map when no package has flags", () => { + const emptyLinker = new Map([ + [0, { flags: [] }], + [1, { flags: [] }] + ]); + + const result = getFlagCounts(emptyLinker); + + assert.deepEqual(result, new Map()); + }); +}); + +describe("getFilterValueCounts", () => { + it("should count license occurrences across packages", () => { + const result = getFilterValueCounts(kLinker, "license"); + + assert.deepEqual(result, new Map([ + ["MIT", 2], + ["ISC", 2] + ])); + }); + + it("should count ext occurrences, ignoring empty strings", () => { + const result = getFilterValueCounts(kLinker, "ext"); + + assert.deepEqual(result, new Map([ + [".js", 3], + [".ts", 1] + ])); + }); + + it("should count builtin module occurrences", () => { + const result = getFilterValueCounts(kLinker, "builtin"); + + assert.deepEqual(result, new Map([ + ["fs", 1], + ["path", 2] + ])); + }); + + it("should count author occurrences, skipping null authors", () => { + const result = getFilterValueCounts(kLinker, "author"); + + assert.deepEqual(result, new Map([ + ["TJ Holowaychuk", 1], + ["John-David Dalton", 1] + ])); + }); + + it("should return empty map for unknown filter name", () => { + const result = getFilterValueCounts(kLinker, "unknown"); + + assert.deepEqual(result, new Map()); + }); +}); + +describe("getHelperValues", () => { + it("should return unique licenses as display/value pairs", () => { + const result = getHelperValues(kLinker, "license"); + + assert.deepEqual(result, [ + { display: "MIT", value: "MIT" }, + { display: "ISC", value: "ISC" } + ]); + }); + + it("should return unique extensions without empty string", () => { + const result = getHelperValues(kLinker, "ext"); + + assert.deepEqual(result, [ + { display: ".js", value: ".js" }, + { display: ".ts", value: ".ts" } + ]); + }); + + it("should return unique Node.js core modules", () => { + const result = getHelperValues(kLinker, "builtin"); + + assert.deepEqual(result, [ + { display: "fs", value: "fs" }, + { display: "path", value: "path" } + ]); + }); + + it("should return unique author names, skipping null authors", () => { + const result = getHelperValues(kLinker, "author"); + + assert.deepEqual(result, [ + { display: "TJ Holowaychuk", value: "TJ Holowaychuk" }, + { display: "John-David Dalton", value: "John-David Dalton" } + ]); + }); + + it("should return empty array for unknown filter name", () => { + const result = getHelperValues(kLinker, "unknown"); + + assert.deepEqual(result, []); + }); +}); diff --git a/views/index.html b/views/index.html index 6b7b41d8..e170fd9b 100644 --- a/views/index.html +++ b/views/index.html @@ -186,4 +186,5 @@

[[=z.token('settings.shortcuts.title')]]

+