feat(cabana): read-only Plugins screen with installed plugins
- GET /plugins (backend.view_plugins, superusers always pass) lists the plugins from party.Installed, or party.Activated IDs for older builds, with the built framework version and whether the registry is configured - Settings -> System -> Plugins entry with the new SettingsEntry.path, added only for principals allowed to open it - PluginsView at /system/plugins with an Installed tab (reka-ui Tabs); settings panel, rail, breadcrumbs and page title know the route - OpenAPI and SPA types regenerated; contract, token-class and conformance inventories list the route; en/pl strings; docs
This commit is contained in:
@@ -1191,6 +1191,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_PluginsOverview": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.PluginsOverview"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_RelationMutationResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -1895,6 +1910,61 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.InstalledPlugin": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"latest": {
|
||||
"type": "string"
|
||||
},
|
||||
"latestFramework": {
|
||||
"type": "string"
|
||||
},
|
||||
"listed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"module": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"updateAvailable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"updateCommand": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"id",
|
||||
"latest",
|
||||
"latestFramework",
|
||||
"listed",
|
||||
"module",
|
||||
"name",
|
||||
"package",
|
||||
"source",
|
||||
"updateAvailable",
|
||||
"updateCommand",
|
||||
"version"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.LangBundle": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
@@ -2453,6 +2523,28 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.PluginsOverview": {
|
||||
"properties": {
|
||||
"framework": {
|
||||
"type": "string"
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.InstalledPlugin"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"registry": {
|
||||
"$ref": "#/components/schemas/cabana.RegistryState"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"framework",
|
||||
"plugins",
|
||||
"registry"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RecordAction": {
|
||||
"properties": {
|
||||
"confirm": {
|
||||
@@ -2562,6 +2654,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RegistryState": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RelationColumn": {
|
||||
"properties": {
|
||||
"key": {
|
||||
@@ -2881,6 +2988,10 @@
|
||||
},
|
||||
"order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"path": {
|
||||
"description": "Path is the admin SPA route an entry of the admin's own opens (the\nPlugins screen, /system/plugins); omitted for plugin entries.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4170,6 +4281,52 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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).",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_PluginsOverview"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"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": "Installed plugins",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/settings": {
|
||||
"get": {
|
||||
"responses": {
|
||||
|
||||
89
admin/src/api/schema.d.ts
vendored
89
admin/src/api/schema.d.ts
vendored
@@ -1217,6 +1217,63 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/plugins": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_PluginsOverview"];
|
||||
};
|
||||
};
|
||||
/** @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;
|
||||
@@ -5856,6 +5913,10 @@ export interface components {
|
||||
data: components["schemas"]["cabana.PartialView"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_PluginsOverview": {
|
||||
data: components["schemas"]["cabana.PluginsOverview"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_RelationMutationResult": {
|
||||
data: components["schemas"]["cabana.RelationMutationResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
@@ -6160,6 +6221,20 @@ export interface components {
|
||||
*/
|
||||
singleton?: boolean;
|
||||
};
|
||||
"cabana.InstalledPlugin": {
|
||||
description: string;
|
||||
id: string;
|
||||
latest: string;
|
||||
latestFramework: string;
|
||||
listed: boolean;
|
||||
module: string;
|
||||
name: string;
|
||||
package: string;
|
||||
source: string;
|
||||
updateAvailable: boolean;
|
||||
updateCommand: string;
|
||||
version: string;
|
||||
};
|
||||
"cabana.LangBundle": {
|
||||
[key: string]: components["schemas"]["cabana.MessageForms"];
|
||||
};
|
||||
@@ -6338,6 +6413,11 @@ export interface components {
|
||||
note: string;
|
||||
value: number;
|
||||
};
|
||||
"cabana.PluginsOverview": {
|
||||
framework: string;
|
||||
plugins: components["schemas"]["cabana.InstalledPlugin"][];
|
||||
registry: components["schemas"]["cabana.RegistryState"];
|
||||
};
|
||||
"cabana.RecordAction": {
|
||||
confirm?: string;
|
||||
/**
|
||||
@@ -6386,6 +6466,10 @@ export interface components {
|
||||
*/
|
||||
title?: string;
|
||||
};
|
||||
"cabana.RegistryState": {
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
};
|
||||
"cabana.RelationColumn": {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -6486,6 +6570,11 @@ export interface components {
|
||||
label: string;
|
||||
model: string;
|
||||
order: number;
|
||||
/**
|
||||
* @description Path is the admin SPA route an entry of the admin's own opens (the
|
||||
* Plugins screen, /system/plugins); omitted for plugin entries.
|
||||
*/
|
||||
path?: string;
|
||||
};
|
||||
"cabana.SettingsMeta": {
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,10 @@ export type AdminAPITokenScope = Schemas['cabana.AdminAPITokenScope']
|
||||
export type NavigationEntry = Schemas['cabana.NavigationEntry']
|
||||
export type SettingsEntry = Schemas['cabana.SettingsEntry']
|
||||
export type SettingsResult = Schemas['cabana.SettingsResult']
|
||||
/** GET /plugins: the built framework version, the registry state and the installed plugins. */
|
||||
export type PluginsOverview = Schemas['cabana.PluginsOverview']
|
||||
export type InstalledPlugin = Schemas['cabana.InstalledPlugin']
|
||||
export type RegistryState = Schemas['cabana.RegistryState']
|
||||
export type ListSchema = Schemas['cabana.ListSchema']
|
||||
export type ListColumn = Schemas['cabana.ListColumn']
|
||||
export type ListFilter = Schemas['cabana.ListFilter']
|
||||
|
||||
@@ -18,6 +18,9 @@ export function pageIndicator(route: RouteLocationNormalizedLoaded): string {
|
||||
if (route.name === 'account') {
|
||||
return t('backend::lang.myaccount.menu_label')
|
||||
}
|
||||
if (route.name === 'plugins') {
|
||||
return t('backend::lang.plugins.title')
|
||||
}
|
||||
if (route.name === 'settings-form') {
|
||||
return settingsEntry(String(route.params.code ?? ''))?.label ?? String(route.params.code ?? '')
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import ReorderView from '../views/ReorderView.vue'
|
||||
import SettingsFormView from '../views/SettingsFormView.vue'
|
||||
import SettingsIndexView from '../views/SettingsIndexView.vue'
|
||||
import AccountView from '../views/AccountView.vue'
|
||||
import PluginsView from '../views/PluginsView.vue'
|
||||
import NotFoundView from '../views/NotFoundView.vue'
|
||||
|
||||
declare module 'vue-router' {
|
||||
@@ -71,6 +72,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
|
||||
{ path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } },
|
||||
// My account: the signed-in admin's own profile and password.
|
||||
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
|
||||
// The read-only Plugins screen (Settings → System, backend.view_plugins).
|
||||
{ path: '/system/plugins', name: 'plugins', component: PluginsView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
|
||||
// The tree reorder page of a list with config_reorder.yaml (D-09).
|
||||
|
||||
@@ -11,7 +11,7 @@ import ShortcutHelpDialog from './ShortcutHelpDialog.vue'
|
||||
import { ExternalLink } from '@lucide/vue'
|
||||
import { controllerIdFromPath } from '../../app/controllerRoutes'
|
||||
import { activeEntry } from '../../state/useNavigation'
|
||||
import { settingsEntry, settingsLinkFor, settingsSectionOf } from '../../state/useSettings'
|
||||
import { settingsEntry, settingsEntryForPath, settingsLinkFor, settingsSectionOf } from '../../state/useSettings'
|
||||
import { useSidebar } from '../../state/useSidebar'
|
||||
import { t } from '../../app/i18n'
|
||||
import { closeShortcutHelp, installHotkeys } from '../../app/hotkeys'
|
||||
@@ -52,6 +52,9 @@ const settingsSection = computed(() => {
|
||||
if (route.name === 'settings-form') {
|
||||
return settingsSectionOf(settingsEntry(String(route.params.code ?? '')))
|
||||
}
|
||||
if (route.name === 'plugins') {
|
||||
return settingsSectionOf(settingsEntryForPath(route.path))
|
||||
}
|
||||
return settingsSectionOf(settingsLinkFor(controllerIdFromPath(route.path)))
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ const crumbs = computed<Crumb[]>(() => {
|
||||
if (route.name === 'account') {
|
||||
return [{ label: t('backend::lang.myaccount.menu_label') }]
|
||||
}
|
||||
if (route.name === 'plugins') {
|
||||
return [{ label: t('backend::lang.nav.settings'), to: '/settings' }, { label: t('backend::lang.plugins.title') }]
|
||||
}
|
||||
if (route.name === 'settings' || route.name === 'settings-form') {
|
||||
const out: Crumb[] = [{ label: t('backend::lang.nav.settings'), to: '/settings' }]
|
||||
const entry = route.name === 'settings-form' ? settingsEntry(String(route.params.code ?? '')) : null
|
||||
|
||||
@@ -7,7 +7,7 @@ import { controllerIdFromPath } from '../../app/controllerRoutes'
|
||||
import { iconFor } from '../../app/icons'
|
||||
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
|
||||
import { cycleTheme, themeChoice } from '../../state/usePreferences'
|
||||
import { hasSettings, settingsLinkFor } from '../../state/useSettings'
|
||||
import { hasSettings, settingsEntryForPath, settingsLinkFor } from '../../state/useSettings'
|
||||
import { useSidebar } from '../../state/useSidebar'
|
||||
import SectionFlyout from './SectionFlyout.vue'
|
||||
|
||||
@@ -45,6 +45,7 @@ const inSettings = computed(
|
||||
() =>
|
||||
route.path === '/settings' ||
|
||||
route.path.startsWith('/settings/') ||
|
||||
settingsEntryForPath(route.path) !== null ||
|
||||
(active.value === null && settingsLinkFor(controllerIdFromPath(route.path)) !== null),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Settings pages the admin may manage (D-21), server-filtered like the
|
||||
// navigation. The rail shows "Ustawienia" only when this list is non-empty.
|
||||
// An entry is either a singleton settings form (/settings/<code>) or a link
|
||||
// that opens an admin controller (its `controller` is set).
|
||||
// An entry is either a singleton settings form (/settings/<code>), a link
|
||||
// that opens an admin controller (its `controller` is set) or one of the
|
||||
// admin's own screens (its `path` is set, for example /system/plugins).
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import { controllerPath } from '../app/controllerRoutes'
|
||||
@@ -28,10 +29,14 @@ export function settingsEntry(code: string): SettingsEntry | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a settings entry opens: its controller for a link entry (the index
|
||||
* when the ID is malformed), otherwise its singleton form.
|
||||
* Route a settings entry opens: its own SPA path for an admin screen, its
|
||||
* controller for a link entry (the index when the ID is malformed),
|
||||
* otherwise its singleton form.
|
||||
*/
|
||||
export function settingsPath(entry: SettingsEntry): string {
|
||||
if (entry.path) {
|
||||
return entry.path
|
||||
}
|
||||
if (entry.controller) {
|
||||
return controllerPath(entry.controller) ?? '/settings'
|
||||
}
|
||||
@@ -46,6 +51,11 @@ export function settingsLinkFor(controllerId: string | null): SettingsEntry | nu
|
||||
return entries.value.find((entry) => entry.controller !== '' && entry.controller === controllerId) ?? null
|
||||
}
|
||||
|
||||
/** The entry that opens the admin screen at path (entry.path), if any. */
|
||||
export function settingsEntryForPath(path: string): SettingsEntry | null {
|
||||
return entries.value.find((entry) => !!entry.path && entry.path === path) ?? null
|
||||
}
|
||||
|
||||
/** Entries grouped by category, categories in first-seen order, by order. */
|
||||
export const settingsGroups = computed(() => {
|
||||
const sorted = entries.value
|
||||
@@ -97,6 +107,7 @@ export function useSettings() {
|
||||
settingsEntry,
|
||||
settingsPath,
|
||||
settingsLinkFor,
|
||||
settingsEntryForPath,
|
||||
settingsSectionOf,
|
||||
}
|
||||
}
|
||||
|
||||
126
admin/src/views/PluginsView.vue
Normal file
126
admin/src/views/PluginsView.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { InstalledPlugin, PluginsOverview } from '../api/types'
|
||||
import { t } from '../app/i18n'
|
||||
|
||||
// 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
|
||||
// text, never as HTML.
|
||||
const overview = ref<PluginsOverview | null>(null)
|
||||
const failed = ref(false)
|
||||
const loading = ref(true)
|
||||
const tab = ref('installed')
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
module: 'backend::lang.plugins.source_module',
|
||||
submodule: 'backend::lang.plugins.source_submodule',
|
||||
local: 'backend::lang.plugins.source_local',
|
||||
}
|
||||
|
||||
function sourceLabel(plugin: InstalledPlugin): string {
|
||||
const key = SOURCE_LABELS[plugin.source]
|
||||
return key ? t(key) : plugin.source
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const { data, response } = await api.GET('/plugins')
|
||||
if (response.ok && data) {
|
||||
overview.value = data.data
|
||||
} else {
|
||||
failed.value = true
|
||||
}
|
||||
} catch {
|
||||
failed.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
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'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-5" data-plugins-screen>
|
||||
<header class="flex flex-col gap-1">
|
||||
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ t('backend::lang.plugins.title') }}</h1>
|
||||
<p v-if="overview?.framework" class="text-[13px] text-muted" data-plugins-framework>
|
||||
{{ t('backend::lang.plugins.framework', { version: overview.framework }) }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p v-if="failed" role="alert" class="flex items-center gap-2 text-danger" data-plugins-failed>
|
||||
<CircleAlert :size="16" class="shrink-0" aria-hidden="true" />
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<TabsContent v-if="overview.registry.enabled" value="marketplace" class="flex flex-col gap-4" />
|
||||
</TabsRoot>
|
||||
|
||||
<p v-else-if="loading" class="text-muted" aria-busy="true">…</p>
|
||||
</section>
|
||||
</template>
|
||||
51
admin/tests/fixtures/lang.json
vendored
51
admin/tests/fixtures/lang.json
vendored
@@ -645,6 +645,57 @@
|
||||
"backend::lang.page.not_found": {
|
||||
"other": "Nie znaleziono strony."
|
||||
},
|
||||
"backend::lang.permissions.view_plugins": {
|
||||
"other": "Podgląd wtyczek"
|
||||
},
|
||||
"backend::lang.plugins.column_plugin": {
|
||||
"other": "Wtyczka"
|
||||
},
|
||||
"backend::lang.plugins.column_source": {
|
||||
"other": "Źródło"
|
||||
},
|
||||
"backend::lang.plugins.column_version": {
|
||||
"other": "Wersja"
|
||||
},
|
||||
"backend::lang.plugins.empty": {
|
||||
"other": "Brak zainstalowanych wtyczek."
|
||||
},
|
||||
"backend::lang.plugins.framework": {
|
||||
"other": "Framework :version"
|
||||
},
|
||||
"backend::lang.plugins.load_failed": {
|
||||
"other": "Nie udało się wczytać listy 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.no_version": {
|
||||
"other": "Brak wersji"
|
||||
},
|
||||
"backend::lang.plugins.source_local": {
|
||||
"other": "W aplikacji"
|
||||
},
|
||||
"backend::lang.plugins.source_module": {
|
||||
"other": "Moduł Go"
|
||||
},
|
||||
"backend::lang.plugins.source_submodule": {
|
||||
"other": "Submoduł git"
|
||||
},
|
||||
"backend::lang.plugins.tab_installed": {
|
||||
"other": "Zainstalowane"
|
||||
},
|
||||
"backend::lang.plugins.tab_marketplace": {
|
||||
"other": "Katalog"
|
||||
},
|
||||
"backend::lang.plugins.tabs_label": {
|
||||
"other": "Sekcje ekranu Wtyczki"
|
||||
},
|
||||
"backend::lang.plugins.title": {
|
||||
"other": "Wtyczki"
|
||||
},
|
||||
"backend::lang.relation.add": {
|
||||
"other": "Dodaj"
|
||||
},
|
||||
|
||||
110
admin/tests/views/PluginsView.test.ts
Normal file
110
admin/tests/views/PluginsView.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount } 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'
|
||||
|
||||
const PLUGINS = `${API}/plugins`
|
||||
|
||||
function installed(overrides: Partial<InstalledPlugin> & { id: string }): InstalledPlugin {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
version: '',
|
||||
source: '',
|
||||
module: '',
|
||||
package: '',
|
||||
listed: false,
|
||||
latest: '',
|
||||
latestFramework: '',
|
||||
updateAvailable: false,
|
||||
updateCommand: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function overview(overrides: Partial<PluginsOverview> = {}): PluginsOverview {
|
||||
return {
|
||||
framework: 'v0.6.0',
|
||||
registry: { enabled: false, status: 'disabled' },
|
||||
plugins: [
|
||||
installed({
|
||||
id: 'acme.blog',
|
||||
name: 'Blog',
|
||||
description: 'Posts <b>and</b> categories.',
|
||||
version: 'v0.1.0',
|
||||
source: 'module',
|
||||
module: 'example.test/acme-blog',
|
||||
package: 'example.test/acme-blog',
|
||||
}),
|
||||
installed({ id: 'acme.local', source: 'local' }),
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const envelope = (data: unknown) => ({ body: { data, meta: {} } })
|
||||
|
||||
async function openPlugins(data: PluginsOverview = overview(), routes: Record<string, Route> = {}) {
|
||||
return mountApp('/system/plugins', { [`GET ${PLUGINS}`]: envelope(data), ...routes }, { attach: true })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('PluginsView', () => {
|
||||
it('renders the installed plugins from GET /plugins', async () => {
|
||||
const { wrapper, calls } = await openPlugins()
|
||||
expect(wrapper.findComponent(PluginsView).exists()).toBe(true)
|
||||
expect(requestsTo(calls, 'GET', PLUGINS)).toHaveLength(1)
|
||||
expect(wrapper.find('h1').text()).toBe('Wtyczki')
|
||||
expect(wrapper.find('[data-plugins-framework]').text()).toBe('Framework v0.6.0')
|
||||
|
||||
const rows = wrapper.findAll('[data-plugin]')
|
||||
expect(rows.map((row) => row.attributes('data-plugin'))).toEqual(['acme.blog', 'acme.local'])
|
||||
const blog = rows[0]!
|
||||
expect(blog.find('[data-plugin-name]').text()).toBe('Blog')
|
||||
expect(blog.find('[data-plugin-version]').text()).toBe('v0.1.0')
|
||||
expect(blog.find('[data-plugin-source]').text()).toBe('Moduł Go')
|
||||
// Registry and plugin text is text, never HTML.
|
||||
expect(blog.find('[data-plugin-description]').text()).toBe('Posts <b>and</b> categories.')
|
||||
expect(blog.find('b').exists()).toBe(false)
|
||||
|
||||
const local = rows[1]!
|
||||
expect(local.find('[data-plugin-name]').text()).toBe('acme.local')
|
||||
expect(local.find('[data-plugin-version]').text()).toBe('Brak wersji')
|
||||
expect(local.find('[data-plugin-source]').text()).toBe('W aplikacji')
|
||||
})
|
||||
|
||||
it('shows an error when the list cannot be loaded', async () => {
|
||||
const { wrapper } = await openPlugins(overview(), {
|
||||
[`GET ${PLUGINS}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden', details: {} } } },
|
||||
})
|
||||
expect(wrapper.find('[data-plugins-failed]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('[data-plugin]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('opens from the settings entry path', () => {
|
||||
const entry: SettingsEntry = {
|
||||
code: 'plugins',
|
||||
label: 'Wtyczki',
|
||||
description: '',
|
||||
category: 'System',
|
||||
icon: 'puzzle',
|
||||
order: 120,
|
||||
keywords: [],
|
||||
model: '',
|
||||
controller: '',
|
||||
path: '/system/plugins',
|
||||
}
|
||||
expect(settingsPath(entry)).toBe('/system/plugins')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user