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:
@@ -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": {
|
||||
|
||||
95
admin/src/api/schema.d.ts
vendored
95
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
51
admin/tests/fixtures/lang.json
vendored
51
admin/tests/fixtures/lang.json
vendored
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user