mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: show plugin features from the manifest on the plugin page
This commit is contained in:
1 parent
60024bdd4e
commit
b6bb3ffc73
13 files changed
+158
-2
No files matched your search
@@ -79,6 +79,12 @@
|
|||||||
"pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
|
"pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
|
||||||
"description": "A YouTube link shown at the top of the plugin's page."
|
"description": "A YouTube link shown at the top of the plugin's page."
|
||||||
},
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 20,
|
||||||
|
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||||
|
"description": "Short lines listing what the plugin does, shown on its page."
|
||||||
|
},
|
||||||
"engine": {
|
"engine": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ const YOUTUBE_HOSTS = new Set([
|
|||||||
"www.youtube-nocookie.com",
|
"www.youtube-nocookie.com",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const MAX_FEATURES = 20;
|
||||||
|
export const MAX_FEATURE_LENGTH = 160;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The usable feature lines of a manifest or registry entry. Anything that is
|
||||||
|
* not a short non-empty string is dropped, so an index cannot flood the page.
|
||||||
|
*/
|
||||||
|
export function pluginFeatures(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter(
|
||||||
|
(f): f is string =>
|
||||||
|
typeof f === "string" &&
|
||||||
|
f.trim().length > 0 &&
|
||||||
|
f.length <= MAX_FEATURE_LENGTH,
|
||||||
|
)
|
||||||
|
.map((f) => f.trim())
|
||||||
|
.slice(0, MAX_FEATURES);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The video id of a YouTube link, or null for anything else. Only the id is
|
* The video id of a YouTube link, or null for anything else. Only the id is
|
||||||
* ever used, so a manifest can never point the embed at another site.
|
* ever used, so a manifest can never point the embed at another site.
|
||||||
@@ -579,6 +599,8 @@ export interface PluginManifest {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
/** A YouTube link shown at the top of the plugin's page. */
|
/** A YouTube link shown at the top of the plugin's page. */
|
||||||
video?: string;
|
video?: string;
|
||||||
|
/** Short lines listing what the plugin does, shown on its page. */
|
||||||
|
features?: string[];
|
||||||
engine: PluginEngine;
|
engine: PluginEngine;
|
||||||
/** Catalog capability ids. */
|
/** Catalog capability ids. */
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
@@ -631,6 +653,7 @@ const ALLOWED_TOP_LEVEL = new Set([
|
|||||||
"repository",
|
"repository",
|
||||||
"icon",
|
"icon",
|
||||||
"video",
|
"video",
|
||||||
|
"features",
|
||||||
"dependencies",
|
"dependencies",
|
||||||
"optionalDependencies",
|
"optionalDependencies",
|
||||||
"provides",
|
"provides",
|
||||||
@@ -792,6 +815,7 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`,
|
`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
validateFeatures(m.features, errors);
|
||||||
for (const field of ["backend", "frontend", "locales"] as const) {
|
for (const field of ["backend", "frontend", "locales"] as const) {
|
||||||
if (field in m && requireString(m[field], field, errors)) {
|
if (field in m && requireString(m[field], field, errors)) {
|
||||||
requireRelativePath(m[field] as string, field, errors);
|
requireRelativePath(m[field] as string, field, errors);
|
||||||
@@ -827,6 +851,28 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateFeatures(features: unknown, errors: string[]): void {
|
||||||
|
if (features === undefined) return;
|
||||||
|
if (!Array.isArray(features)) {
|
||||||
|
errors.push('Field "features" must be an array of strings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (features.length > MAX_FEATURES) {
|
||||||
|
errors.push(`Field "features" may list at most ${MAX_FEATURES} items`);
|
||||||
|
}
|
||||||
|
features.forEach((feature, i) => {
|
||||||
|
if (
|
||||||
|
typeof feature !== "string" ||
|
||||||
|
feature.trim().length === 0 ||
|
||||||
|
feature.length > MAX_FEATURE_LENGTH
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Field "features[${i}]" must be a non-empty string of at most ${MAX_FEATURE_LENGTH} characters`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function validateAuthor(author: unknown, errors: string[]): void {
|
function validateAuthor(author: unknown, errors: string[]): void {
|
||||||
if (author === undefined) return;
|
if (author === undefined) return;
|
||||||
if (!isPlainObject(author)) {
|
if (!isPlainObject(author)) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
|||||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||||
import { PluginManageError } from "../../plugins/manage.js";
|
import { PluginManageError } from "../../plugins/manage.js";
|
||||||
import { youtubeVideoId } from "../../plugins/manifest.js";
|
import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js";
|
||||||
import {
|
import {
|
||||||
isPluginChoice,
|
isPluginChoice,
|
||||||
type PluginChoice,
|
type PluginChoice,
|
||||||
@@ -257,6 +257,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
let author: string | undefined;
|
let author: string | undefined;
|
||||||
let repository: string | undefined;
|
let repository: string | undefined;
|
||||||
let videoId: string | undefined;
|
let videoId: string | undefined;
|
||||||
|
let features: string[] = [];
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse(record.manifestJson) as {
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
contributes?: unknown;
|
contributes?: unknown;
|
||||||
@@ -266,6 +267,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
author?: unknown;
|
author?: unknown;
|
||||||
repository?: unknown;
|
repository?: unknown;
|
||||||
video?: unknown;
|
video?: unknown;
|
||||||
|
features?: unknown;
|
||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
};
|
};
|
||||||
@@ -288,6 +290,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
? manifest.repository
|
? manifest.repository
|
||||||
: undefined;
|
: undefined;
|
||||||
videoId = youtubeVideoId(manifest?.video) ?? undefined;
|
videoId = youtubeVideoId(manifest?.video) ?? undefined;
|
||||||
|
features = pluginFeatures(manifest?.features);
|
||||||
} catch {
|
} catch {
|
||||||
contributes = null;
|
contributes = null;
|
||||||
}
|
}
|
||||||
@@ -318,6 +321,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
author,
|
author,
|
||||||
repository,
|
repository,
|
||||||
videoId,
|
videoId,
|
||||||
|
features,
|
||||||
signedBy: loaded?.signedBy ?? null,
|
signedBy: loaded?.signedBy ?? null,
|
||||||
capabilities,
|
capabilities,
|
||||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ import { invalidatePluginPermissionCache } from "./permissions.js";
|
|||||||
import { moveWithRetry } from "./fs-retry.js";
|
import { moveWithRetry } from "./fs-retry.js";
|
||||||
import { readInstallCounts } from "./install-counts.js";
|
import { readInstallCounts } from "./install-counts.js";
|
||||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||||
import { parseManifest, type PluginManifest } from "./manifest.js";
|
import {
|
||||||
|
parseManifest,
|
||||||
|
pluginFeatures,
|
||||||
|
type PluginManifest,
|
||||||
|
} from "./manifest.js";
|
||||||
import { requireSignedPlugins } from "./trust.js";
|
import { requireSignedPlugins } from "./trust.js";
|
||||||
import {
|
import {
|
||||||
parseChangelog,
|
parseChangelog,
|
||||||
@@ -213,6 +217,7 @@ export interface RegistryListing {
|
|||||||
repository?: string;
|
repository?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
|
features: string[];
|
||||||
versions: Array<{
|
versions: Array<{
|
||||||
version: string;
|
version: string;
|
||||||
compatible: boolean;
|
compatible: boolean;
|
||||||
@@ -272,6 +277,7 @@ export async function listRegistry(
|
|||||||
repository: plugin.repository,
|
repository: plugin.repository,
|
||||||
icon: plugin.icon,
|
icon: plugin.icon,
|
||||||
videoId: plugin.videoId,
|
videoId: plugin.videoId,
|
||||||
|
features: plugin.features,
|
||||||
versions: plugin.versions.map((entry) => ({
|
versions: plugin.versions.map((entry) => ({
|
||||||
version: entry.version,
|
version: entry.version,
|
||||||
compatible: isApiCompatible(entry.api),
|
compatible: isApiCompatible(entry.api),
|
||||||
@@ -1156,6 +1162,7 @@ export interface UploadPreview {
|
|||||||
version: string;
|
version: string;
|
||||||
description: string;
|
description: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
features: string[];
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
/** The version of an earlier upload this one replaces. */
|
/** The version of an earlier upload this one replaces. */
|
||||||
replaces: string | null;
|
replaces: string | null;
|
||||||
@@ -1258,6 +1265,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
|
|||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
description: manifest.description ?? "",
|
description: manifest.description ?? "",
|
||||||
author: manifest.author?.name ?? "",
|
author: manifest.author?.name ?? "",
|
||||||
|
features: pluginFeatures(manifest.features),
|
||||||
capabilities: [...manifest.capabilities],
|
capabilities: [...manifest.capabilities],
|
||||||
replaces,
|
replaces,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
RESERVED_PERMISSION_PREFIXES,
|
RESERVED_PERMISSION_PREFIXES,
|
||||||
qualifyPermission,
|
qualifyPermission,
|
||||||
youtubeVideoId,
|
youtubeVideoId,
|
||||||
|
pluginFeatures,
|
||||||
} from "@termix-ssh/plugin-sdk/manifest";
|
} from "@termix-ssh/plugin-sdk/manifest";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import {
|
import {
|
||||||
|
pluginFeatures,
|
||||||
SUPPORTED_PLUGIN_API_VERSION,
|
SUPPORTED_PLUGIN_API_VERSION,
|
||||||
youtubeVideoId,
|
youtubeVideoId,
|
||||||
} from "@termix-ssh/plugin-sdk/manifest";
|
} from "@termix-ssh/plugin-sdk/manifest";
|
||||||
@@ -57,6 +58,7 @@ export interface RegistryPlugin {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
/** Only ever a YouTube video id, never a link. */
|
/** Only ever a YouTube video id, never a link. */
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
|
features: string[];
|
||||||
/** Newest first. */
|
/** Newest first. */
|
||||||
versions: RegistryVersion[];
|
versions: RegistryVersion[];
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
|||||||
repository: asString(entry.repository),
|
repository: asString(entry.repository),
|
||||||
icon: asString(entry.icon),
|
icon: asString(entry.icon),
|
||||||
videoId: youtubeVideoId(entry.video) ?? undefined,
|
videoId: youtubeVideoId(entry.video) ?? undefined,
|
||||||
|
features: pluginFeatures(entry.features),
|
||||||
versions,
|
versions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SUPPORTED_PLUGIN_API_VERSION,
|
SUPPORTED_PLUGIN_API_VERSION,
|
||||||
isTermixCompatible,
|
isTermixCompatible,
|
||||||
youtubeVideoId,
|
youtubeVideoId,
|
||||||
|
pluginFeatures,
|
||||||
} from "../../plugins/manifest.js";
|
} from "../../plugins/manifest.js";
|
||||||
|
|
||||||
function base(overrides: Record<string, unknown> = {}) {
|
function base(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -885,6 +886,37 @@ describe("manifest video", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("manifest features", () => {
|
||||||
|
it("accepts a short list of lines", () => {
|
||||||
|
expect(
|
||||||
|
validateManifest(base({ features: ["Start containers", "Read logs"] })),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects bad lists", () => {
|
||||||
|
expect(validateManifest(base({ features: "Logs" }))).toEqual([
|
||||||
|
'Field "features" must be an array of strings',
|
||||||
|
]);
|
||||||
|
expect(validateManifest(base({ features: ["ok", "", 4] }))).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
validateManifest(base({ features: ["x".repeat(161)] })).join(),
|
||||||
|
).toMatch(/features\[0\]/);
|
||||||
|
expect(
|
||||||
|
validateManifest(base({ features: Array(21).fill("x") })).join(),
|
||||||
|
).toMatch(/at most 20/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pluginFeatures", () => {
|
||||||
|
it("keeps only usable lines", () => {
|
||||||
|
expect(pluginFeatures([" Logs ", "", 3, "x".repeat(161), "Stats"])).toEqual(
|
||||||
|
["Logs", "Stats"],
|
||||||
|
);
|
||||||
|
expect(pluginFeatures(Array(30).fill("x"))).toHaveLength(20);
|
||||||
|
expect(pluginFeatures("Logs")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("youtubeVideoId", () => {
|
describe("youtubeVideoId", () => {
|
||||||
it("returns only the id", () => {
|
it("returns only the id", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -143,6 +143,17 @@ describe("parseRegistryIndex", () => {
|
|||||||
expect(index.plugins[1].versions[0].notes).toHaveLength(20_000);
|
expect(index.plugins[1].versions[0].notes).toHaveLength(20_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps only usable feature lines", () => {
|
||||||
|
const index = parseRegistryIndex({
|
||||||
|
plugins: [
|
||||||
|
{ id: "docker", features: ["Logs", 5, ""], versions: [version()] },
|
||||||
|
{ id: "other", features: "Logs", versions: [version()] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(index.plugins[0].features).toEqual(["Logs"]);
|
||||||
|
expect(index.plugins[1].features).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses something that is not an index", () => {
|
it("refuses something that is not an index", () => {
|
||||||
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -252,6 +252,8 @@ export interface PluginSummary {
|
|||||||
repository?: string;
|
repository?: string;
|
||||||
/** A YouTube video id from the manifest. Admins only. */
|
/** A YouTube video id from the manifest. Admins only. */
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
|
/** What the plugin does, from the manifest. Admins only. */
|
||||||
|
features?: string[];
|
||||||
signedBy?: string | null;
|
signedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +288,7 @@ export interface RegistryPluginEntry {
|
|||||||
repository?: string;
|
repository?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
|
features?: string[];
|
||||||
versions: RegistryPluginVersion[];
|
versions: RegistryPluginVersion[];
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
@@ -499,6 +502,7 @@ export interface PluginUploadPreview {
|
|||||||
version: string;
|
version: string;
|
||||||
description: string;
|
description: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
features?: string[];
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
replaces: string | null;
|
replaces: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2122,6 +2122,7 @@
|
|||||||
},
|
},
|
||||||
"installedBadge": "Installed",
|
"installedBadge": "Installed",
|
||||||
"incompatible": "Needs a newer Termix",
|
"incompatible": "Needs a newer Termix",
|
||||||
|
"features": "Features",
|
||||||
"whatItCanDo": "What it can do",
|
"whatItCanDo": "What it can do",
|
||||||
"addsToTermix": "Adds to Termix",
|
"addsToTermix": "Adds to Termix",
|
||||||
"addsToTermixHint": "Uninstall and every one of these goes away.",
|
"addsToTermixHint": "Uninstall and every one of these goes away.",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Bug,
|
Bug,
|
||||||
|
Check,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -203,6 +204,22 @@ export function PluginDetail({
|
|||||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||||
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||||
|
|
||||||
|
{plugin.features.length > 0 && (
|
||||||
|
<SectionCard title={t("plugins.manager.features")} icon={null}>
|
||||||
|
<ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 px-4 py-3 sm:grid-cols-2">
|
||||||
|
{plugin.features.map((feature) => (
|
||||||
|
<li
|
||||||
|
key={feature}
|
||||||
|
className="flex items-start gap-2 text-xs leading-snug"
|
||||||
|
>
|
||||||
|
<Check className="mt-px size-3.5 shrink-0 text-accent-brand" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}>
|
<SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}>
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{orderByRisk(plugin.capabilities).map((capability) => (
|
{orderByRisk(plugin.capabilities).map((capability) => (
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface PluginEntry {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
|
features: string[];
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
@@ -94,6 +95,9 @@ export function mergePlugins(
|
|||||||
icon: summary.icon ?? entry?.icon,
|
icon: summary.icon ?? entry?.icon,
|
||||||
repository: summary.repository ?? entry?.repository,
|
repository: summary.repository ?? entry?.repository,
|
||||||
videoId: summary.videoId ?? entry?.videoId,
|
videoId: summary.videoId ?? entry?.videoId,
|
||||||
|
features: summary.features?.length
|
||||||
|
? summary.features
|
||||||
|
: (entry?.features ?? []),
|
||||||
installed: true,
|
installed: true,
|
||||||
version: summary.version,
|
version: summary.version,
|
||||||
latestVersion: entry?.latestVersion ?? null,
|
latestVersion: entry?.latestVersion ?? null,
|
||||||
@@ -130,6 +134,7 @@ export function mergePlugins(
|
|||||||
icon: entry.icon,
|
icon: entry.icon,
|
||||||
repository: entry.repository,
|
repository: entry.repository,
|
||||||
videoId: entry.videoId,
|
videoId: entry.videoId,
|
||||||
|
features: entry.features ?? [],
|
||||||
installed: false,
|
installed: false,
|
||||||
version: null,
|
version: null,
|
||||||
latestVersion: entry.latestVersion,
|
latestVersion: entry.latestVersion,
|
||||||
@@ -163,6 +168,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
|||||||
name: preview.name,
|
name: preview.name,
|
||||||
description: preview.description,
|
description: preview.description,
|
||||||
author: preview.author,
|
author: preview.author,
|
||||||
|
features: preview.features ?? [],
|
||||||
category: "",
|
category: "",
|
||||||
installed: preview.replaces !== null,
|
installed: preview.replaces !== null,
|
||||||
version: preview.replaces,
|
version: preview.replaces,
|
||||||
@@ -226,6 +232,7 @@ export function matchesQuery(row: PluginEntry, query: string): boolean {
|
|||||||
return (
|
return (
|
||||||
row.name.toLowerCase().includes(q) ||
|
row.name.toLowerCase().includes(q) ||
|
||||||
row.description.toLowerCase().includes(q) ||
|
row.description.toLowerCase().includes(q) ||
|
||||||
|
row.features.some((f) => f.toLowerCase().includes(q)) ||
|
||||||
row.id.includes(q)
|
row.id.includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ describe("mergePlugins", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the installed features and falls back to the registry", () => {
|
||||||
|
const registry = entry({ features: ["From registry"] });
|
||||||
|
expect(
|
||||||
|
mergePlugins([summary({ features: ["Installed"] })], [registry])[0]
|
||||||
|
.features,
|
||||||
|
).toEqual(["Installed"]);
|
||||||
|
expect(mergePlugins([summary()], [registry])[0].features).toEqual([
|
||||||
|
"From registry",
|
||||||
|
]);
|
||||||
|
expect(mergePlugins([summary()], null)[0].features).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps installed plugins when the registry is unreachable", () => {
|
it("keeps installed plugins when the registry is unreachable", () => {
|
||||||
const rows = mergePlugins([summary()], null);
|
const rows = mergePlugins([summary()], null);
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
@@ -120,6 +132,7 @@ describe("sorting and filtering", () => {
|
|||||||
name: "Alpha",
|
name: "Alpha",
|
||||||
category: "Access",
|
category: "Access",
|
||||||
description: "Sign in",
|
description: "Sign in",
|
||||||
|
features: ["Works with Podman"],
|
||||||
versions: [
|
versions: [
|
||||||
{
|
{
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -143,6 +156,9 @@ describe("sorting and filtering", () => {
|
|||||||
expect(
|
expect(
|
||||||
rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id),
|
rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id),
|
||||||
).toEqual(["a"]);
|
).toEqual(["a"]);
|
||||||
|
expect(
|
||||||
|
rows.filter((r) => matchesQuery(r, "podman")).map((r) => r.id),
|
||||||
|
).toEqual(["a"]);
|
||||||
expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2);
|
expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user