feat(cabana): plugin updates and marketplace search on the Plugins screen

- GET /plugins looks up each versioned plugin's releases through
  kiosk.Client and applies plugin:update's rule (NewestCompatible above
  the BaseVersion), with the update command and the framework a newer
  release needs
- GET /plugins/marketplace forwards q and category (validated like the
  registry), marks installed and incompatible plugins and returns the
  install command; ids outside the plugin ID shape are dropped
- 4 s per-call timeout, at most 4 lookups at once, in-process cache
  (searches 5 min, releases 15 min, failures 1 min); a failing registry
  gives status unavailable, an empty plugins.registry_url sends nothing
- SPA: update badges and Marketplace tab with copy buttons (clipboard
  fallback), hidden when the registry is disabled; no install button
- OpenAPI, SPA types, inventories, conformance case, en/pl strings, docs
This commit is contained in:
Jakub Zych
2026-10-29 19:20:14 +01:00
parent c4b1ac4076
commit 7e8a19399d
18 changed files with 1624 additions and 79 deletions

View File

@@ -2403,6 +2403,97 @@
],
"type": "object"
},
"cabana.MarketplaceMeta": {
"properties": {
"categories": {
"items": {
"type": "string"
},
"type": "array"
},
"registry": {
"$ref": "#/components/schemas/cabana.RegistryState"
},
"total": {
"type": "integer"
}
},
"required": [
"categories",
"registry",
"total"
],
"type": "object"
},
"cabana.MarketplacePlugin": {
"properties": {
"category": {
"type": "string"
},
"compatible": {
"type": "boolean"
},
"description": {
"type": "string"
},
"featured": {
"type": "boolean"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"installCommand": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"latestFramework": {
"type": "string"
},
"latestVersion": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"category",
"compatible",
"description",
"featured",
"iconUrl",
"id",
"installCommand",
"installed",
"latestFramework",
"latestVersion",
"name"
],
"type": "object"
},
"cabana.MarketplaceResponse": {
"properties": {
"data": {
"items": {
"$ref": "#/components/schemas/cabana.MarketplacePlugin"
},
"type": "array"
},
"meta": {
"$ref": "#/components/schemas/cabana.MarketplaceMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.MessageForms": {
"additionalProperties": {
"type": "string"
@@ -4283,7 +4374,7 @@
},
"/plugins": {
"get": {
"description": "The plugins the application was built with, in summer.yaml order, with the name, description, version and source summer build baked in (only the ID for an application built before plugin metadata existed), the framework version the application was built with and whether the plugin registry is configured (registry.enabled false and registry.status disabled when plugins.registry_url is empty). Requires backend.view_plugins (superusers always pass).",
"description": "The plugins the application was built with, in summer.yaml order, with the name, description, version and source summer build baked in (only the ID for an application built before plugin metadata existed), the framework version the application was built with and the state of the plugin registry. For a plugin the registry lists (listed), latest is the newest release the built framework version allows, updateAvailable says whether it is newer than the installed version (a git describe version compares by its base tag) and updateCommand is then the summer command that installs it; latestFramework names the framework a newer, incompatible release needs. Registry calls are made by the server with a 4 second timeout and cached (versions 15 minutes, failures 1 minute). A registry that is disabled (plugins.registry_url empty, registry.enabled false) or does not answer leaves the list intact and sets registry.status to disabled or unavailable. Requires backend.view_plugins (superusers always pass).",
"responses": {
"200": {
"content": {
@@ -4327,6 +4418,80 @@
]
}
},
"/plugins/marketplace": {
"get": {
"description": "The plugin registry's search for q (matched against ID, name and description, at most 100 characters) and category (^[a-z0-9-]{1,40}$), both optional and forwarded to the registry; other values are a 400 invalid_query. Each plugin carries installed (the application already has it), compatible (the built framework version satisfies the newest release's framework minimum) and installCommand, the summer command that installs it; the admin only shows that command. meta.registry is the registry state, meta.categories the registry's categories for the filter. Searches are cached for 5 minutes. A disabled registry (registry.status disabled) or one that does not answer (unavailable) gives an empty list, never an error. Requires backend.view_plugins (superusers always pass).",
"parameters": [
{
"description": "Search text",
"in": "query",
"name": "q",
"schema": {
"type": "string"
}
},
{
"description": "Category",
"in": "query",
"name": "category",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.MarketplaceResponse"
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Plugin marketplace search",
"tags": [
"admin"
]
}
},
"/settings": {
"get": {
"responses": {

View File

@@ -1226,7 +1226,7 @@ export interface paths {
};
/**
* Installed plugins
* @description The plugins the application was built with, in summer.yaml order, with the name, description, version and source summer build baked in (only the ID for an application built before plugin metadata existed), the framework version the application was built with and whether the plugin registry is configured (registry.enabled false and registry.status disabled when plugins.registry_url is empty). Requires backend.view_plugins (superusers always pass).
* @description The plugins the application was built with, in summer.yaml order, with the name, description, version and source summer build baked in (only the ID for an application built before plugin metadata existed), the framework version the application was built with and the state of the plugin registry. For a plugin the registry lists (listed), latest is the newest release the built framework version allows, updateAvailable says whether it is newer than the installed version (a git describe version compares by its base tag) and updateCommand is then the summer command that installs it; latestFramework names the framework a newer, incompatible release needs. Registry calls are made by the server with a 4 second timeout and cached (versions 15 minutes, failures 1 minute). A registry that is disabled (plugins.registry_url empty, registry.enabled false) or does not answer leaves the list intact and sets registry.status to disabled or unavailable. Requires backend.view_plugins (superusers always pass).
*/
get: {
parameters: {
@@ -1274,6 +1274,77 @@ export interface paths {
patch?: never;
trace?: never;
};
"/plugins/marketplace": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Plugin marketplace search
* @description The plugin registry's search for q (matched against ID, name and description, at most 100 characters) and category (^[a-z0-9-]{1,40}$), both optional and forwarded to the registry; other values are a 400 invalid_query. Each plugin carries installed (the application already has it), compatible (the built framework version satisfies the newest release's framework minimum) and installCommand, the summer command that installs it; the admin only shows that command. meta.registry is the registry state, meta.categories the registry's categories for the filter. Searches are cached for 5 minutes. A disabled registry (registry.status disabled) or one that does not answer (unavailable) gives an empty list, never an error. Requires backend.view_plugins (superusers always pass).
*/
get: {
parameters: {
query?: {
/** @description Search text */
q?: string;
/** @description Category */
category?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.MarketplaceResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings": {
parameters: {
query?: never;
@@ -6379,6 +6450,28 @@ export interface components {
column: string;
direction: string;
};
"cabana.MarketplaceMeta": {
categories: string[];
registry: components["schemas"]["cabana.RegistryState"];
total: number;
};
"cabana.MarketplacePlugin": {
category: string;
compatible: boolean;
description: string;
featured: boolean;
iconUrl: string;
id: string;
installCommand: string;
installed: boolean;
latestFramework: string;
latestVersion: string;
name: string;
};
"cabana.MarketplaceResponse": {
data: components["schemas"]["cabana.MarketplacePlugin"][];
meta: components["schemas"]["cabana.MarketplaceMeta"];
};
"cabana.MessageForms": {
[key: string]: string;
};

View File

@@ -31,6 +31,9 @@ export type SettingsResult = Schemas['cabana.SettingsResult']
export type PluginsOverview = Schemas['cabana.PluginsOverview']
export type InstalledPlugin = Schemas['cabana.InstalledPlugin']
export type RegistryState = Schemas['cabana.RegistryState']
/** GET /plugins/marketplace: the registry's search with install commands. */
export type MarketplaceResponse = Schemas['cabana.MarketplaceResponse']
export type MarketplacePlugin = Schemas['cabana.MarketplacePlugin']
export type ListSchema = Schemas['cabana.ListSchema']
export type ListColumn = Schemas['cabana.ListColumn']
export type ListFilter = Schemas['cabana.ListFilter']

View File

@@ -1,14 +1,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { CircleAlert } from '@lucide/vue'
import { CircleArrowUp, Check, CircleAlert, Copy, Search, Star, TriangleAlert } from '@lucide/vue'
import { api } from '../api/client'
import type { InstalledPlugin, PluginsOverview } from '../api/types'
import type { InstalledPlugin, MarketplaceResponse, PluginsOverview } from '../api/types'
import { t } from '../app/i18n'
import Button from '../components/ui/Button.vue'
// The read-only Plugins screen (Settings → System, backend.view_plugins).
// Installed lists the plugins the binary was built with, as summer build
// baked them (GET /plugins). Every string from the server is rendered as
// baked them, with the newest release the built framework allows (GET
// /plugins). Marketplace searches the plugin registry through cabana (GET
// /plugins/marketplace); the SPA never calls the registry itself (D-17).
// Nothing is installed from here: each update and install shows its summer
// command with a copy button. Every string from the server is rendered as
// text, never as HTML.
const overview = ref<PluginsOverview | null>(null)
const failed = ref(false)
@@ -43,10 +48,136 @@ async function load(): Promise<void> {
}
}
// Marketplace: loaded when its tab first opens, then on every (debounced)
// change of the search or the category.
const SEARCH_DEBOUNCE_MS = 300
const query = ref('')
const category = ref('')
const market = ref<MarketplaceResponse | null>(null)
const marketLoading = ref(false)
const marketFailed = ref(false)
const categories = ref<string[]>([])
let searchTimer: ReturnType<typeof setTimeout> | null = null
let marketRequest = 0
async function loadMarket(): Promise<void> {
const request = ++marketRequest
marketLoading.value = true
marketFailed.value = false
const params: { q?: string; category?: string } = {}
if (query.value.trim() !== '') {
params.q = query.value.trim()
}
if (category.value !== '') {
params.category = category.value
}
try {
const { data, response } = await api.GET('/plugins/marketplace', { params: { query: params } })
if (request !== marketRequest) {
return
}
if (response.ok && data) {
market.value = data
if (data.meta.categories.length > 0) {
categories.value = [...data.meta.categories]
}
} else {
marketFailed.value = true
}
} catch {
if (request === marketRequest) {
marketFailed.value = true
}
} finally {
if (request === marketRequest) {
marketLoading.value = false
}
}
}
function clearSearchTimer(): void {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
function onSearch(value: string): void {
query.value = value
clearSearchTimer()
searchTimer = setTimeout(() => {
searchTimer = null
void loadMarket()
}, SEARCH_DEBOUNCE_MS)
}
function onCategory(value: string): void {
category.value = value
clearSearchTimer()
void loadMarket()
}
watch(tab, (next) => {
if (next === 'marketplace' && market.value === null && !marketLoading.value) {
void loadMarket()
}
})
// Copy buttons (the ApiTokenCreateDialog pattern): the copied state shows
// for two seconds; without a working clipboard the command is selected and
// a hint asks the administrator to copy it.
const copied = ref('')
const copyFailed = ref('')
const announce = ref('')
let copyTimer: ReturnType<typeof setTimeout> | null = null
function selectCommand(key: string): void {
const input = document.querySelector<HTMLInputElement>(`[data-command="${CSS.escape(key)}"]`)
input?.focus()
input?.select()
}
async function copyCommand(key: string, command: string): Promise<void> {
try {
if (!navigator.clipboard?.writeText) {
throw new Error('clipboard unavailable')
}
await navigator.clipboard.writeText(command)
copyFailed.value = ''
copied.value = key
announce.value = t('backend::lang.plugins.copied_announce')
if (copyTimer !== null) {
clearTimeout(copyTimer)
}
copyTimer = setTimeout(() => {
copied.value = ''
copyTimer = null
}, 2000)
} catch {
copied.value = ''
copyFailed.value = key
selectCommand(key)
}
}
function onCommandFocus(event: FocusEvent): void {
;(event.target as HTMLInputElement).select()
}
onMounted(load)
onBeforeUnmount(() => {
clearSearchTimer()
if (copyTimer !== null) {
clearTimeout(copyTimer)
}
})
const tabClass =
'inline-flex h-[34px] items-center gap-2 rounded-tab px-4 font-semibold text-muted transition-colors duration-150 ease-out hover:text-text data-[state=active]:bg-surface data-[state=active]:text-text data-[state=active]:shadow-tab'
const commandClass =
'h-[38px] min-w-0 flex-1 rounded-control border border-border bg-subtle px-3 font-mono text-[13px] text-text'
const selectClass = 'h-button rounded-control border border-border-strong bg-surface px-3 text-text'
</script>
<template>
@@ -63,63 +194,249 @@ const tabClass =
<span>{{ t('backend::lang.plugins.load_failed') }}</span>
</p>
<TabsRoot v-else-if="overview" v-model="tab" class="flex flex-col gap-4">
<TabsList
:aria-label="t('backend::lang.plugins.tabs_label')"
class="inline-flex gap-1 self-start rounded-inner border border-border bg-subtle p-1"
<template v-else-if="overview">
<p
v-if="overview.registry.status === 'unavailable'"
role="status"
class="flex items-center gap-2 rounded-card border border-border bg-subtle px-4 py-3 text-[13px]"
data-registry-unavailable
>
<TabsTrigger value="installed" data-tab="installed" :class="tabClass">
{{ t('backend::lang.plugins.tab_installed') }}
</TabsTrigger>
<TabsTrigger v-if="overview.registry.enabled" value="marketplace" data-tab="marketplace" :class="tabClass">
{{ t('backend::lang.plugins.tab_marketplace') }}
</TabsTrigger>
</TabsList>
<TriangleAlert :size="16" class="shrink-0 text-muted" aria-hidden="true" />
<span>{{ t('backend::lang.plugins.registry_unavailable') }}</span>
</p>
<TabsContent value="installed" class="flex flex-col gap-3">
<p v-if="overview.plugins.length === 0" class="text-muted">{{ t('backend::lang.plugins.empty') }}</p>
<div v-else class="overflow-x-auto rounded-card border border-border bg-surface shadow-card">
<table class="w-full border-collapse text-left">
<thead>
<tr class="border-b border-border text-[13px] text-muted">
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_plugin') }}</th>
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_version') }}</th>
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_source') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="plugin in overview.plugins"
:key="plugin.id"
:data-plugin="plugin.id"
class="border-b border-border align-top last:border-b-0"
>
<td class="px-5 py-3">
<div class="flex min-w-0 flex-col">
<span class="font-semibold" data-plugin-name>{{ plugin.name || plugin.id }}</span>
<span v-if="plugin.name" class="font-mono text-[12px] text-muted">{{ plugin.id }}</span>
<span v-if="plugin.description" class="text-[13px] text-muted" data-plugin-description>{{
plugin.description
<TabsRoot v-model="tab" class="flex flex-col gap-4">
<TabsList
:aria-label="t('backend::lang.plugins.tabs_label')"
class="inline-flex gap-1 self-start rounded-inner border border-border bg-subtle p-1"
>
<TabsTrigger value="installed" data-tab="installed" :class="tabClass">
{{ t('backend::lang.plugins.tab_installed') }}
</TabsTrigger>
<TabsTrigger v-if="overview.registry.enabled" value="marketplace" data-tab="marketplace" :class="tabClass">
{{ t('backend::lang.plugins.tab_marketplace') }}
</TabsTrigger>
</TabsList>
<TabsContent value="installed" class="flex flex-col gap-3">
<p v-if="overview.plugins.length === 0" class="text-muted">{{ t('backend::lang.plugins.empty') }}</p>
<div v-else class="overflow-x-auto rounded-card border border-border bg-surface shadow-card">
<table class="w-full border-collapse text-left">
<thead>
<tr class="border-b border-border text-[13px] text-muted">
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_plugin') }}</th>
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_version') }}</th>
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_source') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="plugin in overview.plugins"
:key="plugin.id"
:data-plugin="plugin.id"
class="border-b border-border align-top last:border-b-0"
>
<td class="px-5 py-3">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="font-semibold" data-plugin-name>{{ plugin.name || plugin.id }}</span>
<span v-if="plugin.name" class="font-mono text-[12px] text-muted">{{ plugin.id }}</span>
<span v-if="plugin.description" class="text-[13px] text-muted" data-plugin-description>{{
plugin.description
}}</span>
<div
v-if="overview.registry.enabled && plugin.updateAvailable"
class="mt-2 flex max-w-[560px] flex-col gap-1.5"
data-plugin-update
>
<span
class="inline-flex items-center gap-1.5 self-start rounded-pill bg-sel px-2.5 py-0.5 text-[12px] font-semibold"
data-update-badge
>
<CircleArrowUp :size="14" aria-hidden="true" />
{{ t('backend::lang.plugins.update_available', { version: plugin.latest }) }}
</span>
<div class="flex items-center gap-2">
<label :for="`plugin-command-update-${plugin.id}`" class="sr-only">{{
t('backend::lang.plugins.command_label')
}}</label>
<input
:id="`plugin-command-update-${plugin.id}`"
:data-command="`update:${plugin.id}`"
:value="plugin.updateCommand"
type="text"
readonly
spellcheck="false"
:class="commandClass"
@focus="onCommandFocus"
/>
<Button
variant="outline"
size="sm"
:icon="copied === `update:${plugin.id}` ? Check : Copy"
data-action="copy-command"
@click="copyCommand(`update:${plugin.id}`, plugin.updateCommand)"
>
{{
copied === `update:${plugin.id}`
? t('backend::lang.plugins.copied')
: t('backend::lang.plugins.copy')
}}
</Button>
</div>
<p v-if="copyFailed === `update:${plugin.id}`" role="alert" class="text-[13px] text-danger" data-copy-failed>
{{ t('backend::lang.plugins.copy_failed') }}
</p>
</div>
<span
v-if="overview.registry.enabled && plugin.latestFramework"
class="text-[12px] text-muted"
data-plugin-needs-framework
>{{ t('backend::lang.plugins.needs_framework', { version: plugin.latestFramework }) }}</span
>
</div>
</td>
<td class="px-5 py-3 whitespace-nowrap">
<span v-if="plugin.version" class="font-mono text-[13px]" data-plugin-version>{{
plugin.version
}}</span>
</div>
</td>
<td class="px-5 py-3 whitespace-nowrap">
<span v-if="plugin.version" class="font-mono text-[13px]" data-plugin-version>{{ plugin.version }}</span>
<span v-else class="text-[13px] text-muted" data-plugin-version>{{
t('backend::lang.plugins.no_version')
}}</span>
</td>
<td class="px-5 py-3 text-[13px] whitespace-nowrap" data-plugin-source>
{{ plugin.source ? sourceLabel(plugin) : '' }}
</td>
</tr>
</tbody>
</table>
</div>
</TabsContent>
<span v-else class="text-[13px] text-muted" data-plugin-version>{{
t('backend::lang.plugins.no_version')
}}</span>
</td>
<td class="px-5 py-3 text-[13px] whitespace-nowrap" data-plugin-source>
{{ plugin.source ? sourceLabel(plugin) : '' }}
</td>
</tr>
</tbody>
</table>
</div>
</TabsContent>
<TabsContent v-if="overview.registry.enabled" value="marketplace" class="flex flex-col gap-4" />
</TabsRoot>
<TabsContent v-if="overview.registry.enabled" value="marketplace" class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-3">
<label class="relative block w-[360px] max-w-full">
<span class="sr-only">{{ t('backend::lang.plugins.search_label') }}</span>
<Search
:size="18"
class="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
type="search"
maxlength="100"
data-market-search
:value="query"
:placeholder="t('backend::lang.plugins.search_placeholder')"
class="h-button w-full rounded-control border border-transparent bg-subtle pr-3.5 pl-10"
@input="onSearch(($event.target as HTMLInputElement).value)"
/>
</label>
<label class="flex flex-col gap-1 text-[13px]">
<span class="font-semibold text-muted">{{ t('backend::lang.plugins.category_label') }}</span>
<select
data-market-category
:value="category"
:class="selectClass"
@change="onCategory(($event.target as HTMLSelectElement).value)"
>
<option value="">{{ t('backend::lang.plugins.category_all') }}</option>
<option v-for="name in categories" :key="name" :value="name">{{ name }}</option>
</select>
</label>
</div>
<p class="text-[13px] text-muted">{{ t('backend::lang.plugins.install_hint') }}</p>
<p
v-if="marketFailed || market?.meta.registry.status === 'unavailable'"
role="status"
class="flex items-center gap-2 text-[13px]"
data-market-unavailable
>
<TriangleAlert :size="16" class="shrink-0 text-muted" aria-hidden="true" />
<span>{{ t('backend::lang.plugins.registry_unavailable') }}</span>
</p>
<p v-else-if="market && market.data.length === 0" class="text-muted" data-market-empty>
{{ t('backend::lang.plugins.marketplace_empty') }}
</p>
<div v-if="market && market.data.length > 0" class="grid grid-cols-1 gap-3 min-[1100px]:grid-cols-2">
<article
v-for="item in market.data"
:key="item.id"
:data-market-plugin="item.id"
class="flex flex-col gap-3 rounded-card border border-border bg-surface p-5 shadow-card"
>
<div class="flex items-start gap-3.5">
<img
v-if="item.iconUrl"
:src="item.iconUrl"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
class="size-10 shrink-0 rounded-inner object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold" data-market-name>{{ item.name || item.id }}</span>
<span v-if="item.latestVersion" class="font-mono text-[12px] text-muted" data-market-version>{{
item.latestVersion
}}</span>
<Star v-if="item.featured" :size="14" class="text-muted" :aria-label="t('backend::lang.plugins.featured')" />
</div>
<span class="font-mono text-[12px] text-muted">{{ item.id }}</span>
<span v-if="item.description" class="text-[13px] text-muted" data-market-description>{{
item.description
}}</span>
</div>
<span
v-if="item.installed"
class="shrink-0 rounded-pill bg-subtle px-2.5 py-0.5 text-[12px] font-semibold"
data-market-marker="installed"
>{{ t('backend::lang.plugins.marker_installed') }}</span
>
<span
v-else-if="!item.compatible"
class="shrink-0 rounded-pill bg-subtle px-2.5 py-0.5 text-[12px] font-semibold text-muted"
data-market-marker="needs-framework"
>{{ t('backend::lang.plugins.marker_needs_framework', { version: item.latestFramework }) }}</span
>
</div>
<div class="flex items-center gap-2">
<label :for="`plugin-command-install-${item.id}`" class="sr-only">{{
t('backend::lang.plugins.command_label')
}}</label>
<input
:id="`plugin-command-install-${item.id}`"
:data-command="`install:${item.id}`"
:value="item.installCommand"
type="text"
readonly
spellcheck="false"
:class="commandClass"
@focus="onCommandFocus"
/>
<Button
variant="outline"
size="sm"
:icon="copied === `install:${item.id}` ? Check : Copy"
data-action="copy-command"
@click="copyCommand(`install:${item.id}`, item.installCommand)"
>
{{
copied === `install:${item.id}` ? t('backend::lang.plugins.copied') : t('backend::lang.plugins.copy')
}}
</Button>
</div>
<p v-if="copyFailed === `install:${item.id}`" role="alert" class="text-[13px] text-danger" data-copy-failed>
{{ t('backend::lang.plugins.copy_failed') }}
</p>
</article>
</div>
</TabsContent>
</TabsRoot>
<span class="sr-only" aria-live="polite">{{ announce }}</span>
</template>
<p v-else-if="loading" class="text-muted" aria-busy="true">…</p>
</section>

View File

@@ -648,6 +648,12 @@
"backend::lang.permissions.view_plugins": {
"other": "Podgląd wtyczek"
},
"backend::lang.plugins.category_all": {
"other": "Wszystkie kategorie"
},
"backend::lang.plugins.category_label": {
"other": "Kategoria"
},
"backend::lang.plugins.column_plugin": {
"other": "Wtyczka"
},
@@ -657,24 +663,66 @@
"backend::lang.plugins.column_version": {
"other": "Wersja"
},
"backend::lang.plugins.command_label": {
"other": "Polecenie"
},
"backend::lang.plugins.copied": {
"other": "Skopiowano"
},
"backend::lang.plugins.copied_announce": {
"other": "Skopiowano polecenie do schowka."
},
"backend::lang.plugins.copy": {
"other": "Kopiuj"
},
"backend::lang.plugins.copy_failed": {
"other": "Nie udało się skopiować. Polecenie jest zaznaczone; skopiuj je skrótem Ctrl+C."
},
"backend::lang.plugins.empty": {
"other": "Brak zainstalowanych wtyczek."
},
"backend::lang.plugins.featured": {
"other": "Wyróżniona"
},
"backend::lang.plugins.framework": {
"other": "Framework :version"
},
"backend::lang.plugins.install_hint": {
"other": "Wtyczki instaluje się i aktualizuje z wiersza poleceń: uruchom polecenie w katalogu aplikacji, potem summer build, i wdróż nowy plik binarny."
},
"backend::lang.plugins.load_failed": {
"other": "Nie udało się wczytać listy wtyczek."
},
"backend::lang.plugins.marker_installed": {
"other": "Zainstalowana"
},
"backend::lang.plugins.marker_needs_framework": {
"other": "Wymaga frameworka :version"
},
"backend::lang.plugins.marketplace_empty": {
"other": "Brak pasujących wtyczek."
},
"backend::lang.plugins.menu_description": {
"other": "Zainstalowane wtyczki, ich wersje i dostępne aktualizacje oraz katalog wtyczek."
},
"backend::lang.plugins.menu_label": {
"other": "Wtyczki"
},
"backend::lang.plugins.needs_framework": {
"other": "Nowsze wydanie wymaga frameworka :version."
},
"backend::lang.plugins.no_version": {
"other": "Brak wersji"
},
"backend::lang.plugins.registry_unavailable": {
"other": "Katalog wtyczek nie odpowiada, więc informacje o aktualizacjach i katalog są chwilowo niedostępne."
},
"backend::lang.plugins.search_label": {
"other": "Szukaj wtyczek"
},
"backend::lang.plugins.search_placeholder": {
"other": "Szukaj po nazwie lub opisie"
},
"backend::lang.plugins.source_local": {
"other": "W aplikacji"
},
@@ -696,6 +744,9 @@
"backend::lang.plugins.title": {
"other": "Wtyczki"
},
"backend::lang.plugins.update_available": {
"other": "Dostępna aktualizacja do :version"
},
"backend::lang.relation.add": {
"other": "Dodaj"
},

View File

@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import PluginsView from '../../src/views/PluginsView.vue'
import { settingsPath } from '../../src/state/useSettings'
import type { InstalledPlugin, PluginsOverview, SettingsEntry } from '../../src/api/types'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
import type { InstalledPlugin, MarketplacePlugin, PluginsOverview, SettingsEntry } from '../../src/api/types'
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
const PLUGINS = `${API}/plugins`
const MARKET = `${API}/plugins/marketplace`
function installed(overrides: Partial<InstalledPlugin> & { id: string }): InstalledPlugin {
return {
@@ -56,6 +57,7 @@ beforeEach(() => {
afterEach(() => {
document.body.innerHTML = ''
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
})
enableAutoUnmount(afterEach)
@@ -107,4 +109,163 @@ describe('PluginsView', () => {
}
expect(settingsPath(entry)).toBe('/system/plugins')
})
describe('with the registry', () => {
const registry = { enabled: true, status: 'ok' }
function withUpdate(): PluginsOverview {
return overview({
registry,
plugins: [
installed({
id: 'acme.blog',
name: 'Blog',
version: 'v0.1.0',
source: 'module',
listed: true,
latest: 'v0.1.1',
latestFramework: 'v0.7.0',
updateAvailable: true,
updateCommand: 'summer plugin:update acme.blog',
}),
installed({ id: 'acme.translate', version: 'v0.1.1-3-gabc1234', source: 'submodule', listed: true, latest: 'v0.1.1' }),
],
})
}
function marketPlugin(overrides: Partial<MarketplacePlugin> & { id: string }): MarketplacePlugin {
return {
name: '',
description: '',
category: 'content',
featured: false,
iconUrl: '',
latestVersion: 'v0.1.0',
latestFramework: 'v0.6.0',
installed: false,
compatible: true,
installCommand: `summer plugin:install ${overrides.id}`,
...overrides,
}
}
const marketBody = (data: MarketplacePlugin[], status = 'ok') => ({
body: { data, meta: { registry: { enabled: true, status }, total: data.length, categories: ['commerce', 'content'] } },
})
async function openMarketplace(wrapper: VueWrapper): Promise<void> {
await wrapper.find('[data-tab="marketplace"]').trigger('mousedown', { button: 0 })
await flushPromises()
}
it('shows the update badge with a copyable update command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const { wrapper } = await openPlugins(withUpdate())
const blog = wrapper.find('[data-plugin="acme.blog"]')
expect(blog.find('[data-update-badge]').text()).toBe('Dostępna aktualizacja do v0.1.1')
expect(blog.find<HTMLInputElement>('[data-command="update:acme.blog"]').element.value).toBe('summer plugin:update acme.blog')
expect(blog.find('[data-plugin-needs-framework]').text()).toBe('Nowsze wydanie wymaga frameworka v0.7.0.')
expect(wrapper.find('[data-plugin="acme.translate"] [data-plugin-update]').exists()).toBe(false)
await blog.find('[data-action="copy-command"]').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('summer plugin:update acme.blog')
expect(blog.find('[data-action="copy-command"]').text()).toBe('Skopiowano')
expect(blog.find('[data-copy-failed]').exists()).toBe(false)
})
it('falls back to selecting the command without a clipboard', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
const { wrapper } = await openPlugins(withUpdate())
const blog = wrapper.find('[data-plugin="acme.blog"]')
await blog.find('[data-action="copy-command"]').trigger('click')
await flushPromises()
expect(blog.find('[data-copy-failed]').text()).toContain('Nie udało się skopiować')
expect(document.activeElement).toBe(blog.find('[data-command="update:acme.blog"]').element)
expect(blog.find('[data-action="copy-command"]').text()).toBe('Kopiuj')
})
it('hides the marketplace tab and the badges when the registry is disabled', async () => {
const disabled = withUpdate()
disabled.registry = { enabled: false, status: 'disabled' }
const { wrapper, calls } = await openPlugins(disabled)
expect(wrapper.find('[data-tab="installed"]').exists()).toBe(true)
expect(wrapper.find('[data-tab="marketplace"]').exists()).toBe(false)
expect(wrapper.find('[data-update-badge]').exists()).toBe(false)
expect(wrapper.find('[data-plugin-needs-framework]').exists()).toBe(false)
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(0)
})
it('keeps the installed list and shows a notice when the registry is unavailable', async () => {
const down = overview({ registry: { enabled: true, status: 'unavailable' } })
const { wrapper } = await openPlugins(down)
expect(wrapper.find('[data-registry-unavailable]').exists()).toBe(true)
expect(wrapper.findAll('[data-plugin]')).toHaveLength(2)
})
it('searches the marketplace through cabana and offers install commands only', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const results = [
marketPlugin({ id: 'acme.blog', name: 'Blog', installed: true }),
marketPlugin({ id: 'acme.journal', name: 'Journal', description: 'A <b>journal</b>.', featured: true }),
marketPlugin({ id: 'acme.shop', name: 'Shop', compatible: false, latestFramework: 'v0.9.0', category: 'commerce' }),
]
const { wrapper, calls } = await openPlugins(withUpdate(), {
[`GET ${MARKET}`]: (request) => {
const q = queryOf(request).get('q') ?? ''
const category = queryOf(request).get('category') ?? ''
return marketBody(results.filter((p) => p.id.includes(q) && (category === '' || p.category === category)))
},
})
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(0)
await openMarketplace(wrapper)
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(1)
const cards = wrapper.findAll('[data-market-plugin]')
expect(cards.map((card) => card.attributes('data-market-plugin'))).toEqual(['acme.blog', 'acme.journal', 'acme.shop'])
expect(cards[0]!.find('[data-market-marker="installed"]').text()).toBe('Zainstalowana')
expect(cards[2]!.find('[data-market-marker="needs-framework"]').text()).toBe('Wymaga frameworka v0.9.0')
expect(cards[1]!.find('[data-market-description]').text()).toBe('A <b>journal</b>.')
expect(cards[1]!.find('b').exists()).toBe(false)
expect(cards[1]!.find<HTMLInputElement>('[data-command="install:acme.journal"]').element.value).toBe(
'summer plugin:install acme.journal',
)
// The only buttons on a card copy the command: nothing installs from here.
for (const card of cards) {
expect(card.findAll('button').map((button) => button.attributes('data-action'))).toEqual(['copy-command'])
}
const options = wrapper.findAll('[data-market-category] option').map((option) => option.attributes('value'))
expect(options).toEqual(['', 'commerce', 'content'])
await cards[1]!.find('[data-action="copy-command"]').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('summer plugin:install acme.journal')
await wrapper.find('[data-market-search]').setValue('journal')
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(1)
await wait(350)
await flushPromises()
const searches = requestsTo(calls, 'GET', MARKET)
expect(searches).toHaveLength(2)
expect(queryOf(searches[1]!).get('q')).toBe('journal')
expect(wrapper.findAll('[data-market-plugin]')).toHaveLength(1)
await wrapper.find('[data-market-category]').setValue('commerce')
await flushPromises()
const filtered = requestsTo(calls, 'GET', MARKET)
expect(queryOf(filtered[2]!).get('category')).toBe('commerce')
expect(wrapper.find('[data-market-empty]').exists()).toBe(true)
})
it('shows the registry notice when the marketplace search fails', async () => {
const { wrapper } = await openPlugins(withUpdate(), {
[`GET ${MARKET}`]: marketBody([], 'unavailable'),
})
await openMarketplace(wrapper)
expect(wrapper.find('[data-market-unavailable]').exists()).toBe(true)
expect(wrapper.findAll('[data-market-plugin]')).toHaveLength(0)
})
})
})