diff --git a/.changeset/12675-installed-apps-keys.md b/.changeset/12675-installed-apps-keys.md new file mode 100644 index 0000000000..de162b8057 --- /dev/null +++ b/.changeset/12675-installed-apps-keys.md @@ -0,0 +1,9 @@ +--- +'@object-ui/i18n': minor +--- + +Seven `marketplace.*` keys are removed from all ten language packs: `installedTitle`, `installedSubtitle`, `installedEmpty`, `installedAt`, `installedBy`, `installedPackageId` and `cachedAs` (objectui#12675). Only the Installed Apps list read them. That list's body is retired: Setup → Packages is the one list of what is installed, and its rows do not show an install date, an installer or the cached-manifest path. A host that read one of these keys gets the key back instead of a sentence. + +Fix for a host that still reads one of them: carry the sentence in the host's own bundle, or drop the read. + +Clause-②: yes (narrowing) diff --git a/.changeset/12675-packages-source.md b/.changeset/12675-packages-source.md new file mode 100644 index 0000000000..621c8fdd6c --- /dev/null +++ b/.changeset/12675-packages-source.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': minor +--- + +Setup → Packages is the one list of what is installed: every package carries a Source, and marketplace installs carry their actions on their own row (objectui#12675, ruling B on objectstack#23892). + +- **Source column.** Each row reads `Code`, `Marketplace` or `Authored`, a closed set. `Authored` is a row whose served `writable` verdict is `true` (a database-authored base). `Marketplace` is a non-writable row whose id the marketplace lists as installed: the control plane's list in a cloud-connected environment, this runtime's install-local list otherwise. Every other non-writable row is `Code`. A row with no boolean `writable`, or a writable row the marketplace also lists, is refused by its id, and the list says so instead of drawing a blank or a guessed Source. When the install-local capability is on and its installed list cannot be read, the list is refused the same way, so a marketplace install is never shown as `Code`. +- **Marketplace row actions.** When the runtime reports the install-local capability (`features.installLocal`), the rows whose Source is `Marketplace` carry the actions the Installed Apps list had, for a workspace admin: **Details** opens the package's marketplace page, where Update and the sample-data Re-seed / Purge menu are. **Uninstall** is offered for this runtime's own installs, behind an in-app confirm. An install the runtime refused to load still gets its row, with its Not loaded reason and Uninstall. With the capability off, rows still show a Source and no row carries a marketplace action. With `features.installLocal` off no row reads `Marketplace`, so in such an environment control-plane installs read `Code`; the declared key objectstack#23972 adds removes that case. +- **Source filter.** `?source=code`, `?source=marketplace` or `?source=authored` on the Packages route (`/apps/APP/component/developer/packages`) filters the list. A Setup nav item gets the same URL with `{ type: 'component', componentRef: 'developer:packages', params: { source: 'marketplace' } }`. An unknown, empty or repeated value is refused by name and no rows are drawn. +- **Installed Apps opens Packages filtered to Marketplace.** The `marketplace:installed-list` widget, which the install-local plugin's Installed Apps page renders, and the legacy `system/marketplace/installed` route both redirect to `/apps/APP/component/developer/packages?source=marketplace`. The widget registration, the route and the `MarketplaceInstalledPage` export are kept until the plugin's entry points at that URL (objectstack#23933). +- **Copy.** The new Packages copy is in the metadata-admin designer's own string tables (en and zh). The Packages rows do not show the Installed Apps list's install date, installer or cached-manifest path; the language-pack keys that only that list read are removed (the `@object-ui/i18n` changeset beside this one). + +Clause-②: yes (narrowing) diff --git a/packages/app-shell/src/console/marketplace/InstalledListWidget.tsx b/packages/app-shell/src/console/marketplace/InstalledListWidget.tsx index d3906ec0cf..507736667a 100644 --- a/packages/app-shell/src/console/marketplace/InstalledListWidget.tsx +++ b/packages/app-shell/src/console/marketplace/InstalledListWidget.tsx @@ -1,252 +1,35 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Installed-packages list — registered as the SDUI widget - * `marketplace:installed-list` (cloud ADR-0009 P2a). + * `marketplace:installed-list` — the SDUI widget the install-local plugin's + * Installed Apps page renders (cloud ADR-0009 P2a; objectstack's + * `MarketplaceInstalledPage` in `@objectstack/cloud-connection`). * - * The Installed Apps page ships as METADATA with - * `@objectstack/cloud-connection`'s install-local plugin; this widget is - * the interactive body: load (control-plane list when cloud-connected, - * local install-local cache otherwise), refresh, uninstall. The legacy - * React route (`MarketplaceInstalledPage`) renders the SAME component - * during the ADR-0009 migration window — single implementation, two - * entries. + * Setup → Packages is now the one list of what is installed (objectui#12675, + * ruling B on objectstack#23892): every package carries its Source, and the + * rows whose Source is `marketplace` carry the actions this list used to + * (Details, Uninstall — `MarketplaceRowActions`). So this widget no longer + * draws a second list: it opens Packages filtered to Source = marketplace, + * `/apps/APP/component/developer/packages?source=marketplace`. + * + * It stays registered only so the published `marketplace_installed` page keeps + * landing somewhere until objectstack#23933 retargets the plugin's Installed + * Apps entry at that URL; the registration, this file, the legacy + * `system/marketplace/installed` routes and the `MarketplaceInstalledPage` + * export retire in the follow-up after that lands. */ -import { useEffect, useId, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Badge, - Button, - Skeleton, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@object-ui/components'; -import { RefreshCcw, Trash2, AlertCircle, ExternalLink } from 'lucide-react'; -import { useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import { Navigate, useParams } from 'react-router-dom'; import { ComponentRegistry } from '@object-ui/core'; -import { - listLocalInstalls, - listInstalledPackages, - uninstallLocal, - type LocalInstallEntry, - type LocalInstallNotLoaded, -} from './marketplaceApi.js'; -import { MarketplaceAccessDenied } from './MarketplaceAccessDenied.js'; +import { packagesHref } from '../../views/metadata-admin/packageSource.js'; export function InstalledList() { - const navigate = useNavigate(); const { appName } = useParams<{ appName?: string }>(); - const { isAdmin } = useWorkspaceAdminStatus(); - const { t } = useObjectTranslation(); - // The install date reads the DISPLAY locale, never the UI language: a - // regional locale (`de-CH` under an English UI) must reach it, and an empty - // language must not fall through to the machine's locale (objectui#10331). - const displayLocale = useDisplayLocale(); - const basePath = appName ? `/apps/${appName}` : ''; - // One description node serves every loaded row's Uninstall: the kernel note - // is the same sentence for each of them (objectui#12610). - const kernelNoteId = useId(); - - const [items, setItems] = useState([]); - // 'cloud' = list comes from the control plane (CLI/marketplace/REST installs, - // ADR-0007 step ①); 'local' = self-hosted install-local cache. - const [source, setSource] = useState<'cloud' | 'local'>('local'); - const [loading, setLoading] = useState(true); - const [working, setWorking] = useState(null); - const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); - - const load = async () => { - setLoading(true); - try { - // Cloud-connected env → authoritative installed list is the control - // plane's. Self-hosted (not bound) → fall back to the local cache. - const cloud = await listInstalledPackages(); - if (cloud.connected) { - setSource('cloud'); - setItems(cloud.items); - } else { - setSource('local'); - setItems(await listLocalInstalls()); - } - } finally { - setLoading(false); - } - }; - - useEffect(() => { void load(); }, []); - - // objectui#11645 — the reason a `notLoaded` entry was refused, in plain words. - // A code the console has no sentence for still reads as not loaded, naming - // the code, rather than hiding the row the operator needs to uninstall. - const notLoadedReason = (marker: LocalInstallNotLoaded): string => - marker.code === 'OS_PROTOCOL_INCOMPATIBLE' - ? t('marketplace.notLoaded.protocolIncompatible', { requiredRange: marker.requiredRange }) - : t('marketplace.notLoaded.otherReason', { code: marker.code }); - - const doUninstall = async (entry: LocalInstallEntry) => { - // A package the runtime did not load leaves nothing in the running kernel, - // so its confirm and its result drop the "stays loaded until restart" - // caveat that a loaded package's texts carry, which would contradict the - // row's own "Not loaded" (objectui#11645). - const question = entry.notLoaded - ? t('marketplace.uninstall.confirmNotLoaded', { manifestId: entry.manifestId, version: entry.version }) - : t('marketplace.uninstall.confirm', { manifestId: entry.manifestId, version: entry.version }); - if (!confirm(question)) { - return; - } - setWorking(entry.manifestId); - setResult(null); - try { - await uninstallLocal(entry.manifestId); - setResult({ - ok: true, - message: entry.notLoaded - ? t('marketplace.uninstall.successNotLoaded', { manifestId: entry.manifestId }) - : t('marketplace.uninstall.successInList', { manifestId: entry.manifestId }), - }); - await load(); - } catch (e: any) { - setResult({ ok: false, message: e?.message ?? String(e) }); - } finally { - setWorking(null); - } - }; - - // The kernel note is about a LOADED package: the running kernel keeps it - // registered until restart. It rides the Uninstall it qualifies, as a - // tooltip, instead of a paragraph under the list (objectui#12610). A - // not-loaded entry left nothing in the kernel (objectui#11645), so its - // Uninstall carries no note. - const uninstallAction = (entry: LocalInstallEntry) => { - const button = ( - - ); - if (entry.notLoaded) return button; - return ( - - - {button} - -

{t('marketplace.installedAdditiveNote')}

-
-
-
- ); - }; - - if (!isAdmin) return ; - - return ( -
-
- -
- - {result && ( -
-
-
-
- )} - - {loading && items.length === 0 ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) : items.length === 0 ? ( -
-

{t('marketplace.installedEmpty')}

- -
- ) : ( -
- {items.map((entry) => ( - - -
- - {entry.manifestId} - {t('marketplace.versionBadge', { version: entry.version })} - {entry.notLoaded && ( - {t('marketplace.notLoaded.badge')} - )} - -
- {t('marketplace.installedAt', { when: new Date(entry.installedAt).toLocaleString(displayLocale) })} - {entry.installedBy && {t('marketplace.installedBy', { user: entry.installedBy })}} - {t('marketplace.installedPackageId')} {entry.packageId} -
- {entry.notLoaded && ( -

-

- )} -
-
- - {source === 'local' && uninstallAction(entry)} -
-
- {source === 'local' && ( - - )} -
- ))} -
- )} - - {/* The Uninstall tooltip only shows on hover or focus; this is what a - screen reader announces as the button's description. */} - {source === 'local' && items.some((entry) => !entry.notLoaded) && ( - {t('marketplace.installedAdditiveNote')} - )} -
- ); + // Packages is a Setup page; outside an app route there is no app segment to + // keep, so the Setup app's own (`/apps/setup`, the spelling the console's + // no-app empty state uses) is where it lives. + const basePath = appName ? `/apps/${appName}` : '/apps/setup'; + return ; } // SDUI registration — the metadata page shipped by the install-local plugin diff --git a/packages/app-shell/src/console/marketplace/MarketplaceInstalledPage.tsx b/packages/app-shell/src/console/marketplace/MarketplaceInstalledPage.tsx index d465067a59..011e2f09e3 100644 --- a/packages/app-shell/src/console/marketplace/MarketplaceInstalledPage.tsx +++ b/packages/app-shell/src/console/marketplace/MarketplaceInstalledPage.tsx @@ -1,51 +1,17 @@ /** - * Marketplace Installed Apps Page (legacy React route). + * Marketplace Installed Apps Page (legacy React route, + * `system/marketplace/installed`). * - * The Installed Apps surface is migrating to plugin-carried metadata - * (cloud ADR-0009 P2a): the page shell ships with - * @objectstack/cloud-connection's install-local plugin and renders the - * `marketplace:installed-list` widget. This route remains during the - * migration window (the marketplace page's "Installed (N)" button links - * here) and renders the SAME widget — single implementation, two entries. + * Setup → Packages is the one installed list now (objectui#12675): this route + * opens it filtered to Source = marketplace, through the same + * `marketplace:installed-list` body the plugin-carried page renders, so the + * marketplace page's "Installed (N)" button and the plugin's entry land on the + * same view. The route and this export retire in the follow-up after + * objectstack#23933 retargets the plugin's entry. */ -import { useNavigate, useParams } from 'react-router-dom'; -import { Button } from '@object-ui/components'; -import { ArrowLeft, Store } from 'lucide-react'; -import { useObjectTranslation } from '@object-ui/i18n'; import { InstalledList } from './InstalledListWidget.js'; export function MarketplaceInstalledPage() { - const navigate = useNavigate(); - const { appName } = useParams<{ appName?: string }>(); - const { t } = useObjectTranslation(); - const basePath = appName ? `/apps/${appName}` : ''; - - return ( -
- - -
-
-
-

path; render translated HTML from our own bundle. - dangerouslySetInnerHTML={{ __html: t('marketplace.installedSubtitle') }} - /> -

- - -
- ); + return ; } diff --git a/packages/app-shell/src/console/marketplace/MarketplaceRowActions.tsx b/packages/app-shell/src/console/marketplace/MarketplaceRowActions.tsx new file mode 100644 index 0000000000..def2dd2817 --- /dev/null +++ b/packages/app-shell/src/console/marketplace/MarketplaceRowActions.tsx @@ -0,0 +1,173 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The marketplace actions on a Setup → Packages row whose Source is + * `marketplace` (objectui#12675, ruling B on objectstack#23892). + * + * Moved here from the Installed Apps list (`InstalledList`), which carried + * exactly these two: **Details**, which opens the package's marketplace page + * (where Update and the sample-data Re-seed / Purge menu live), and + * **Uninstall**, offered only when the list is this runtime's install-local + * ledger (a cloud-connected environment's installs are managed by the control + * plane). The Packages page renders this only when the marketplace capability + * (`features.installLocal`) is present and the viewer is a workspace admin — + * the same two gates the Installed Apps page had. + * + * The confirm is in-app (`AlertDialog`), the Packages page's convention + * (objectui#11784), with the Installed Apps sentences unchanged. + */ + +import { useId, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + Button, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@object-ui/components'; +import { AlertCircle, ExternalLink, Trash2 } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { uninstallLocal, type LocalInstallEntry, type LocalInstallNotLoaded } from './marketplaceApi.js'; + +/** + * The reason a `notLoaded` entry was refused, in plain words (objectui#11645). + * A code the console has no sentence for still reads as not loaded, naming + * the code, rather than hiding the row the operator needs to uninstall. + */ +export function MarketplaceNotLoaded({ marker }: { marker: LocalInstallNotLoaded }) { + const { t } = useObjectTranslation(); + const reason = + marker.code === 'OS_PROTOCOL_INCOMPATIBLE' + ? t('marketplace.notLoaded.protocolIncompatible', { requiredRange: marker.requiredRange }) + : t('marketplace.notLoaded.otherReason', { code: marker.code }); + return ( +
+ {t('marketplace.notLoaded.badge')} +

+

+
+ ); +} + +export interface MarketplaceRowActionsProps { + entry: LocalInstallEntry; + /** Which list the entry came from; Uninstall is offered on `local` only. */ + listSource: 'cloud' | 'local'; + /** `/apps/APP`, or `''` outside an app. */ + basePath: string; + /** The outcome of an action, for the page to show. */ + onResult: (result: { ok: boolean; message: string }) => void; + /** Called after a successful uninstall, so the page reloads its list. */ + onChanged: () => void; +} + +export function MarketplaceRowActions({ entry, listSource, basePath, onResult, onChanged }: MarketplaceRowActionsProps) { + const navigate = useNavigate(); + const { t } = useObjectTranslation(); + const kernelNoteId = useId(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [working, setWorking] = useState(false); + + // A package the runtime did not load leaves nothing in the running kernel, + // so its confirm and its result drop the "stays loaded until restart" + // caveat that a loaded package's texts carry, which would contradict the + // row's own "Not loaded" (objectui#11645). + const question = entry.notLoaded + ? t('marketplace.uninstall.confirmNotLoaded', { manifestId: entry.manifestId, version: entry.version }) + : t('marketplace.uninstall.confirm', { manifestId: entry.manifestId, version: entry.version }); + + const doUninstall = async () => { + setConfirmOpen(false); + setWorking(true); + try { + await uninstallLocal(entry.manifestId); + onResult({ + ok: true, + message: entry.notLoaded + ? t('marketplace.uninstall.successNotLoaded', { manifestId: entry.manifestId }) + : t('marketplace.uninstall.successInList', { manifestId: entry.manifestId }), + }); + onChanged(); + } catch (e: any) { + onResult({ ok: false, message: e?.message ?? String(e) }); + } finally { + setWorking(false); + } + }; + + const uninstallButton = ( + + ); + + return ( + // The row itself opens the package's detail sheet; a click on an action + // must not also open it. +
e.stopPropagation()}> + + {listSource === 'local' && + (entry.notLoaded ? ( + uninstallButton + ) : ( + // The kernel note is about a LOADED package: the running kernel keeps + // it registered until restart. It rides the Uninstall it qualifies, + // as a tooltip (objectui#12610); the sr-only copy is what a screen + // reader announces as the button's description. + <> + + + {uninstallButton} + +

{t('marketplace.installedAdditiveNote')}

+
+
+
+ {t('marketplace.installedAdditiveNote')} + + ))} + + + + {t('marketplace.action.uninstall')} + {question} + + + {t('common.cancel')} + void doUninstall()} data-testid={`pkg-row-uninstall-confirm-${entry.manifestId}`}> + {t('marketplace.action.uninstall')} + + + + +
+ ); +} diff --git a/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.kernelNoteTooltip-12610.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.kernelNoteTooltip-12610.test.tsx deleted file mode 100644 index a54e45983e..0000000000 --- a/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.kernelNoteTooltip-12610.test.tsx +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Installed Apps' kernel note rides the Uninstall it qualifies, as a tooltip, - * instead of a paragraph under the list (objectui#12610). - * - * The settings walk measured the "kernel API is additive only" paragraph under - * the EMPTY state, where there is nothing to uninstall. The note says what - * Uninstall does to a loaded package (the running kernel keeps it until - * restart), so it belongs on that action, and only where it applies: - * - * - the empty state carries no note; - * - a loaded local row's Uninstall is described by the note and opens a - * tooltip with it, as plain text — no markup is drawn literally; - * - a not-loaded row's Uninstall carries no note: that package left nothing - * in the running kernel (objectui#11645); - * - every pack's value is plain text, since a tooltip renders it as text. - * - * The wire is stubbed `fetch` against the REAL `marketplaceApi`, as the - * not-loaded file beside this one does; text is the PACK's value for its key, - * through the real `I18nProvider`, never a sentence copied into this file. - */ - -import '@testing-library/jest-dom/vitest'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { I18nProvider } from '@object-ui/i18n'; -import { builtInLocales, en, zh } from '@object-ui/i18n/locales'; - -vi.mock('react-router-dom', () => { - const navigate = () => {}; - const params = {}; - return { useNavigate: () => navigate, useParams: () => params }; -}); - -vi.mock('@object-ui/auth', async (importOriginal) => ({ - ...(await importOriginal>()), - useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), -})); - -import { InstalledList } from '../InstalledListWidget'; - -type Pack = typeof en; -const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; -const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; - -const LOADED = { - packageId: 'com.example.todo', - versionId: 'ver_todo_1', - manifestId: 'com.example.todo', - version: '1.0.0', - installedAt: '2026-10-05T09:00:00.000Z', - withSampleData: true, - installedBy: 'admin@objectos.ai', -}; - -const REFUSED = { - packageId: 'com.example.crm', - versionId: 'ver_crm_3', - manifestId: 'com.example.crm', - version: '3.2.0', - installedAt: '2026-10-05T09:00:00.000Z', - notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: '^16' }, - installedBy: 'admin@objectos.ai', -}; - -const answer = (status: number, body: unknown) => ({ - ok: status >= 200 && status < 300, - status, - statusText: '', - json: async () => body, -}); - -/** Not cloud-bound: the widget lists the local install cache, `entries`. */ -function serveLocal(entries: Array>) { - vi.stubGlobal( - 'fetch', - vi.fn(async (input: unknown) => { - const path = new URL(String(input), 'http://console.test').pathname; - if (path === '/api/v1/cloud-connection/installed') return answer(404, {}); - if (path === '/api/v1/marketplace/install-local') { - return answer(200, { success: true, data: { items: entries, total: entries.length } }); - } - return answer(404, {}); - }), - ); -} - -function renderList(config: typeof EN | typeof ZH) { - return render( - - - , - ); -} - -const TITLE = { selector: 'div.font-semibold' } as const; - -async function card(manifestId: string): Promise { - const title = await screen.findByText(manifestId, TITLE); - const el = title.closest('div.rounded-lg'); - if (!el) throw new Error(`no card around ${manifestId}`); - return el as HTMLElement; -} - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -describe.each([ - ['en', EN, en as Pack], - ['zh', ZH, zh as unknown as Pack], -])('InstalledList — the kernel note is the Uninstall tooltip (objectui#12610) [%s]', (_lang, config, pack) => { - const note = pack.marketplace.installedAdditiveNote; - - it('the empty state carries no kernel note', async () => { - serveLocal([]); - renderList(config); - expect(await screen.findByText(pack.marketplace.installedEmpty)).toBeInTheDocument(); - expect(document.body.textContent).not.toContain(note); - }); - - it('a loaded row\'s Uninstall is described by the note, and hovering it opens a tooltip with it', async () => { - const user = userEvent.setup(); - serveLocal([LOADED]); - renderList(config); - - const row = await card('com.example.todo'); - const uninstall = within(row).getByRole('button', { name: pack.marketplace.action.uninstall }); - expect(uninstall).toHaveAccessibleDescription(note); - // The note is not a paragraph on the page any more: the only copy outside - // the tooltip is the one the button's description points at. - const described = document.getElementById(uninstall.getAttribute('aria-describedby') as string); - expect(described).toHaveClass('sr-only'); - - await user.hover(uninstall); - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent(note); - // Plain text: no tag drawn literally, no element injected from the value. - expect(tooltip.textContent).not.toMatch(/<\/?[a-z]/i); - expect(tooltip.querySelector('strong')).toBeNull(); - }); - - it('a not-loaded row\'s Uninstall carries no note — that package left nothing in the kernel', async () => { - serveLocal([REFUSED]); - renderList(config); - - const row = await card('com.example.crm'); - const uninstall = within(row).getByRole('button', { name: pack.marketplace.action.uninstall }); - expect(uninstall).not.toHaveAttribute('aria-describedby'); - expect(document.body.textContent).not.toContain(note); - }); -}); - -describe('every pack\'s kernel note is plain text (objectui#12610)', () => { - const packs = Object.entries(builtInLocales) as Array<[string, Pack]>; - - it('the walk below reads all ten packs', () => { - expect(packs.map(([lang]) => lang).sort()).toEqual( - ['ar', 'de', 'en', 'es', 'fr', 'ja', 'ko', 'pt', 'ru', 'zh'], - ); - }); - - it.each(packs)('%s: `marketplace.installedAdditiveNote` carries no markup', (_lang, pack) => { - const note = pack.marketplace.installedAdditiveNote; - expect(note.length).toBeGreaterThan(20); - expect(note).not.toMatch(/<\/?[a-z][^>]*>/i); - }); -}); diff --git a/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.notLoaded-11645.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.notLoaded-11645.test.tsx deleted file mode 100644 index 603d3bb4ea..0000000000 --- a/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.notLoaded-11645.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Installed Apps reads an entry the runtime refused to load as NOT LOADED, names - * the reason, and keeps Uninstall on it (objectui#11645). - * - * ## The defect - * - * After a restart whose startup rehydrate refused a protocol-incompatible - * package, `GET /api/v1/marketplace/install-local` still lists it (so DELETE and - * a compatible re-install stay reachable), and since objectstack#21822 (PR - * objectstack#21833, `@objectstack/*` 17.7.0) it marks the entry with - * `notLoaded: { code, requiredRange }` IN PLACE of `withSampleData`. - * `InstalledList` drew every listed entry as installed, so the console said - * "installed" for a package the runtime holds none of. - * - * ## Why the cases drive the WIRE - * - * `marketplaceApi` is NOT mocked. One stubbed `fetch` answers by path with the - * listing body the server landed, verbatim in shape, over a stateful ledger, so - * "Uninstall works" means a DELETE left for that manifest id and the re-read - * listing no longer carries the row — the card's own acceptance. - * - * ## What the cases assert about text - * - * The rendered line is compared with the PACK's value for its key, read from - * the pack and interpolated here, never with a sentence copied into this file: - * the claim is which key renders, with which values, through the real - * `I18nProvider`, in `en` and in `zh`. Wording stays the translators'. - */ - -import '@testing-library/jest-dom/vitest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { I18nProvider } from '@object-ui/i18n'; -import { en, zh } from '@object-ui/i18n/locales'; - -vi.mock('react-router-dom', () => { - const navigate = () => {}; - const params = {}; - return { useNavigate: () => navigate, useParams: () => params }; -}); - -vi.mock('@object-ui/auth', async (importOriginal) => ({ - ...(await importOriginal>()), - useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), -})); - -import { InstalledList } from '../InstalledListWidget'; - -type Pack = typeof en; -const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; -const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; - -/** `{{name}}` interpolation, as i18next applies it to these keys (no escaping: the console sets `escapeValue: false`). */ -const fill = (template: string, values: Record) => - template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => values[k] ?? `{{${k}}}`); - -/** - * The refused entry exactly as objectstack PR #21833's wire shape gives it to an - * operator (`manage_metadata`): `notLoaded` closed to `code` + `requiredRange`, - * and NO `withSampleData` key — omitted, not `false`. - */ -const REFUSED = { - packageId: 'com.example.crm', - versionId: 'ver_crm_3', - manifestId: 'com.example.crm', - version: '3.2.0', - installedAt: '2026-10-05T09:00:00.000Z', - notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: '^16' }, - installedBy: 'admin@objectos.ai', -}; - -/** A loaded entry, byte-identical to the listing before #21833: `withSampleData`, no `notLoaded` key. */ -const LOADED = { - packageId: 'com.example.todo', - versionId: 'ver_todo_1', - manifestId: 'com.example.todo', - version: '1.0.0', - installedAt: '2026-10-05T09:00:00.000Z', - withSampleData: true, - installedBy: 'admin@objectos.ai', -}; - -/** The same refused entry as a narrowed caller (no `manage_metadata`) reads it: no `installedBy`, marker kept. */ -const { installedBy: _operatorOnly, ...REFUSED_NARROWED } = REFUSED; - -interface WireCall { path: string; method: string } -let calls: WireCall[] = []; -let ledger: Array> = []; -let confirmed: string[] = []; - -const answer = (status: number, body: unknown) => ({ - ok: status >= 200 && status < 300, - status, - statusText: '', - json: async () => body, -}); - -/** Serve the install-local doors over `entries`, statefully: DELETE removes the entry, as the server's ledger does. */ -function serve(entries: Array>) { - ledger = entries.map((e) => ({ ...e })); - calls = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: unknown, init?: { method?: string }) => { - const path = new URL(String(input), 'http://console.test').pathname; - const method = (init?.method ?? 'GET').toUpperCase(); - calls.push({ path, method }); - // Not cloud-bound: the widget falls back to the local listing. - if (path === '/api/v1/cloud-connection/installed') return answer(404, {}); - if (path === '/api/v1/marketplace/install-local' && method === 'GET') { - return answer(200, { success: true, data: { items: ledger, total: ledger.length } }); - } - const del = /^\/api\/v1\/marketplace\/install-local\/([^/]+)$/.exec(path); - if (del && method === 'DELETE') { - const manifestId = decodeURIComponent(del[1]); - ledger = ledger.filter((e) => e.manifestId !== manifestId); - return answer(200, { success: true, data: { manifestId, cleanups: [] } }); - } - return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } }); - }), - ); -} - -function renderList(config: typeof EN | typeof ZH) { - return render( - - - , - ); -} - -/** A row's title: the `CardTitle` naming `manifestId` (the row's `` also prints the package id, so the selector is what tells them apart). */ -const TITLE = { selector: 'div.font-semibold' } as const; - -/** The card whose title names `manifestId`. */ -async function card(manifestId: string): Promise { - const title = await screen.findByText(manifestId, TITLE); - const el = title.closest('div.rounded-lg'); - if (!el) throw new Error(`no card around ${manifestId}`); - return el as HTMLElement; -} - -beforeEach(() => { - confirmed = []; - vi.stubGlobal('confirm', (question: string) => { - confirmed.push(question); - return true; - }); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -describe.each([ - ['en', EN, en as Pack], - ['zh', ZH, zh as unknown as Pack], -])('InstalledList — a refused entry reads not loaded, with its reason (objectui#11645) [%s]', (_lang, config, pack) => { - const badge = pack.marketplace.notLoaded.badge; - const reason = fill(pack.marketplace.notLoaded.protocolIncompatible, { requiredRange: '^16' }); - - it('the operator\'s refused entry carries the not-loaded badge and the protocol reason, naming the range', async () => { - serve([REFUSED, LOADED]); - renderList(config); - - const row = await card('com.example.crm'); - expect(within(row).getByText(badge)).toBeInTheDocument(); - expect(within(row).getByText(reason)).toBeInTheDocument(); - // Uninstall is still offered on the row, and enabled. - expect(within(row).getByRole('button', { name: pack.marketplace.action.uninstall })).toBeEnabled(); - }); - - it('a narrowed caller\'s entry (no `installedBy`) still reads not loaded, with its reason', async () => { - serve([REFUSED_NARROWED]); - renderList(config); - - const row = await card('com.example.crm'); - expect(within(row).getByText(badge)).toBeInTheDocument(); - expect(within(row).getByText(reason)).toBeInTheDocument(); - // Lit control: the operator-only line is the thing absent, not the row. - expect(within(row).queryByText(fill(pack.marketplace.installedBy, { user: 'admin@objectos.ai' }))).toBeNull(); - }); - - it('a loaded entry carries neither the badge nor a reason', async () => { - // A preservation pin: it holds on both sides of the fix. The cases above - // are what show these queries find the badge and the reason when present. - serve([REFUSED, LOADED]); - renderList(config); - - const row = await card('com.example.todo'); - expect(within(row).queryByText(badge)).toBeNull(); - // The static head of either reason sentence (the text before its placeholder). - for (const template of [pack.marketplace.notLoaded.protocolIncompatible, pack.marketplace.notLoaded.otherReason]) { - const head = template.split('{{')[0]; - expect(head.length).toBeGreaterThan(3); - expect(row.textContent).not.toContain(head); - } - expect(within(row).getByRole('button', { name: pack.marketplace.action.uninstall })).toBeEnabled(); - }); -}); - -describe('InstalledList — Uninstall works from the not-loaded row (objectui#11645)', () => { - it('confirms in not-loaded terms, issues DELETE for the manifest id, and the re-read listing drops the row', async () => { - serve([REFUSED, LOADED]); - renderList(EN); - - const row = await card('com.example.crm'); - fireEvent.click(within(row).getByRole('button', { name: en.marketplace.action.uninstall })); - - await waitFor(() => - expect(calls).toContainEqual({ path: '/api/v1/marketplace/install-local/com.example.crm', method: 'DELETE' }), - ); - expect(confirmed).toEqual([ - fill(en.marketplace.uninstall.confirmNotLoaded, { manifestId: 'com.example.crm', version: '3.2.0' }), - ]); - expect( - await screen.findByText(fill(en.marketplace.uninstall.successNotLoaded, { manifestId: 'com.example.crm' })), - ).toBeInTheDocument(); - // The listing was read again after the DELETE, and no longer has the row. - await waitFor(() => expect(screen.queryByText('com.example.crm', TITLE)).toBeNull()); - expect(calls.filter((c) => c.path === '/api/v1/marketplace/install-local' && c.method === 'GET')).toHaveLength(2); - expect(screen.getByText('com.example.todo', TITLE)).toBeInTheDocument(); - }); - - it('control: a loaded row keeps the loaded package\'s confirm and result', async () => { - serve([REFUSED, LOADED]); - renderList(EN); - - const row = await card('com.example.todo'); - fireEvent.click(within(row).getByRole('button', { name: en.marketplace.action.uninstall })); - - await waitFor(() => - expect(calls).toContainEqual({ path: '/api/v1/marketplace/install-local/com.example.todo', method: 'DELETE' }), - ); - expect(confirmed).toEqual([ - fill(en.marketplace.uninstall.confirm, { manifestId: 'com.example.todo', version: '1.0.0' }), - ]); - expect( - await screen.findByText(fill(en.marketplace.uninstall.successInList, { manifestId: 'com.example.todo' })), - ).toBeInTheDocument(); - }); -}); - -describe('InstalledList — a refusal code the console has no sentence for (objectui#11645)', () => { - it('still reads not loaded, names the code, and keeps the row and its Uninstall', async () => { - serve([{ ...REFUSED, notLoaded: { code: 'OS_SOME_LATER_REFUSAL', requiredRange: '^18' } }]); - renderList(EN); - - const row = await card('com.example.crm'); - expect(within(row).getByText(en.marketplace.notLoaded.badge)).toBeInTheDocument(); - expect(within(row).getByText(fill(en.marketplace.notLoaded.otherReason, { code: 'OS_SOME_LATER_REFUSAL' }))).toBeInTheDocument(); - // The protocol sentence is the one code's, never a guess for another. - expect(row.textContent).not.toContain('^18'); - expect(within(row).getByRole('button', { name: en.marketplace.action.uninstall })).toBeEnabled(); - }); -}); diff --git a/packages/app-shell/src/console/marketplace/__tests__/MarketplaceRowActions.installedAppsPins.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/MarketplaceRowActions.installedAppsPins.test.tsx new file mode 100644 index 0000000000..107429b909 --- /dev/null +++ b/packages/app-shell/src/console/marketplace/__tests__/MarketplaceRowActions.installedAppsPins.test.tsx @@ -0,0 +1,306 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Installed Apps list's pins, carried onto the Setup → Packages rows whose + * Source is marketplace (objectui#12675 moved the list's actions there and + * retired its body). + * + * - objectui#11645 — an install the runtime refused to load still has its row: + * it reads NOT LOADED with its reason, keeps Uninstall, and confirms and + * reports in not-loaded terms. A code the console has no sentence for is + * named, not hidden. + * - objectui#12610 — the kernel note ("the running kernel keeps the app until + * restart") rides the Uninstall of a LOADED row as a tooltip and its + * accessible description; a not-loaded row's Uninstall carries none; every + * pack's value is plain text. + * - objectui#7611 — a walled uninstall refusal reaches the operator with the + * door's own sentence, never a bare status, and the row stays. + * + * `marketplaceApi` is not mocked: one stubbed `fetch` answers by path over a + * stateful install-local ledger. Text is the PACK's value for its key through + * the real `I18nProvider`, never a sentence copied here. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales, en, zh } from '@object-ui/i18n/locales'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +import { PackagesPage } from '../../../views/metadata-admin/PackagesPage'; +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; + +type Pack = typeof en; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; + +/** `{{name}}` interpolation, as i18next applies it to these keys (`escapeValue: false`). */ +const fill = (template: string, values: Record) => + template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => values[k] ?? `{{${k}}}`); + +/** A refused entry as objectstack PR #21833 lists it: `notLoaded`, no `withSampleData`. */ +const REFUSED = { + packageId: 'com.example.crm', + versionId: 'ver_crm_3', + manifestId: 'com.example.crm', + version: '3.2.0', + installedAt: '2026-10-05T09:00:00.000Z', + notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: '^16' }, + installedBy: 'admin@objectos.ai', +}; +/** The same entry as a narrowed caller reads it: no `installedBy`, marker kept. */ +const { installedBy: _operatorOnly, ...REFUSED_NARROWED } = REFUSED; +/** A loaded entry: `withSampleData`, no `notLoaded`. */ +const LOADED = { + packageId: 'com.example.todo', + versionId: 'ver_todo_1', + manifestId: 'com.example.todo', + version: '1.0.0', + installedAt: '2026-10-05T09:00:00.000Z', + withSampleData: true, + installedBy: 'admin@objectos.ai', +}; +/** The loaded entry's registry row. A refused entry has none: the runtime holds none of it. */ +const LOADED_ROW = { manifest: { id: 'com.example.todo', name: 'Todo', version: '1.0.0' }, enabled: true, writable: false }; + +const UNINSTALL_PATH = (id: string) => `/api/v1/marketplace/install-local/${id}`; + +interface WireCall { path: string; method: string } +let calls: WireCall[] = []; +let ledger: Array> = []; + +const reply = (status: number, body: unknown, statusText = '') => + new Response(JSON.stringify(body), { status, statusText, headers: { 'content-type': 'application/json' } }); + +/** + * Boot the runtime with the install-local capability, then serve the package + * list and the install-local doors over `entries`. DELETE removes the entry, as + * the server's ledger does — unless `refuseDelete` answers it. + */ +async function serve(entries: Array>, refuseDelete?: () => Response) { + resetRuntimeConfigForTesting(); + vi.stubGlobal('fetch', vi.fn(async () => reply(200, { features: { installLocal: true } }))); + await initRuntimeConfig(); + ledger = entries.map((e) => ({ ...e })); + calls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: { method?: string }) => { + const path = new URL(String(input), 'http://console.test').pathname; + const method = (init?.method ?? 'GET').toUpperCase(); + calls.push({ path, method }); + if (path === '/api/v1/packages') { + const loaded = ledger.some((e) => e.manifestId === 'com.example.todo' && !e.notLoaded); + return reply(200, { success: true, data: { packages: loaded ? [LOADED_ROW] : [] } }); + } + // Not cloud-bound: the marketplace slice reads the local ledger. + if (path === '/api/v1/cloud-connection/installed') return reply(404, {}); + if (path === '/api/v1/marketplace/install-local' && method === 'GET') { + return reply(200, { success: true, data: { items: ledger, total: ledger.length } }); + } + const del = /^\/api\/v1\/marketplace\/install-local\/([^/]+)$/.exec(path); + if (del && method === 'DELETE') { + if (refuseDelete) return refuseDelete(); + const manifestId = decodeURIComponent(del[1]); + ledger = ledger.filter((e) => e.manifestId !== manifestId); + return reply(200, { success: true, data: { manifestId, cleanups: [] } }); + } + return reply(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } }); + }), + ); +} + +function renderPackages(config: typeof EN | typeof ZH) { + return render( + + + + + , + ); +} + +const row = (manifestId: string) => screen.findByTestId(`pkg-row-${manifestId}`); + +/** Whitespace collapsed, as an accessible description reads. */ +const flat = (text: string) => text.replace(/\s+/g, ' ').trim(); + +/** Uninstall, then confirm in the in-app dialog; returns the dialog's question as announced. */ +async function uninstallAndConfirm(manifestId: string): Promise { + fireEvent.click(within(await row(manifestId)).getByTestId(`pkg-row-uninstall-${manifestId}`)); + const dialog = await screen.findByTestId(`pkg-row-uninstall-dialog-${manifestId}`); + const describedBy = dialog.getAttribute('aria-describedby'); + const question = flat((describedBy && document.getElementById(describedBy)?.textContent) || ''); + fireEvent.click(within(dialog).getByTestId(`pkg-row-uninstall-confirm-${manifestId}`)); + return question; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); +}); + +describe.each([ + ['en', EN, en as Pack], + ['zh', ZH, zh as unknown as Pack], +])('Packages — a refused marketplace install reads not loaded, with its reason (objectui#11645) [%s]', (_lang, config, pack) => { + const badge = pack.marketplace.notLoaded.badge; + const reason = fill(pack.marketplace.notLoaded.protocolIncompatible, { requiredRange: '^16' }); + + it('the refused entry has a marketplace row with the badge and the protocol reason, and Uninstall', async () => { + await serve([REFUSED, LOADED]); + renderPackages(config); + + const r = await row('com.example.crm'); + expect(within(r).getByTestId('pkg-source')).toHaveAttribute('data-source', 'marketplace'); + expect(within(r).getByText(badge)).toBeInTheDocument(); + expect(within(r).getByText(reason)).toBeInTheDocument(); + expect(within(r).getByRole('button', { name: pack.marketplace.action.uninstall })).toBeEnabled(); + }); + + it('a narrowed caller\'s entry (no `installedBy`) still reads not loaded, with its reason', async () => { + await serve([REFUSED_NARROWED]); + renderPackages(config); + + const r = await row('com.example.crm'); + expect(within(r).getByText(badge)).toBeInTheDocument(); + expect(within(r).getByText(reason)).toBeInTheDocument(); + }); + + it('a loaded entry carries neither the badge nor a reason', async () => { + await serve([REFUSED, LOADED]); + renderPackages(config); + + const r = await row('com.example.todo'); + expect(within(r).queryByText(badge)).toBeNull(); + for (const template of [pack.marketplace.notLoaded.protocolIncompatible, pack.marketplace.notLoaded.otherReason]) { + const head = template.split('{{')[0]; + expect(head.length).toBeGreaterThan(3); + expect(r.textContent).not.toContain(head); + } + expect(within(r).getByRole('button', { name: pack.marketplace.action.uninstall })).toBeEnabled(); + }); +}); + +describe('Packages — Uninstall from a marketplace row (objectui#11645)', () => { + it('confirms in not-loaded terms, issues DELETE for the manifest id, and the re-read list drops the row', async () => { + await serve([REFUSED, LOADED]); + renderPackages(EN); + + const question = await uninstallAndConfirm('com.example.crm'); + expect(question).toBe(flat(fill(en.marketplace.uninstall.confirmNotLoaded, { manifestId: 'com.example.crm', version: '3.2.0' }))); + await waitFor(() => expect(calls).toContainEqual({ path: UNINSTALL_PATH('com.example.crm'), method: 'DELETE' })); + expect( + await screen.findByText(fill(en.marketplace.uninstall.successNotLoaded, { manifestId: 'com.example.crm' })), + ).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByTestId('pkg-row-com.example.crm')).toBeNull()); + expect(calls.filter((c) => c.path === '/api/v1/marketplace/install-local' && c.method === 'GET')).toHaveLength(2); + expect(screen.getByTestId('pkg-row-com.example.todo')).toBeInTheDocument(); + }); + + it('control: a loaded row keeps the loaded package\'s confirm and result', async () => { + await serve([REFUSED, LOADED]); + renderPackages(EN); + + const question = await uninstallAndConfirm('com.example.todo'); + expect(question).toBe(flat(fill(en.marketplace.uninstall.confirm, { manifestId: 'com.example.todo', version: '1.0.0' }))); + await waitFor(() => expect(calls).toContainEqual({ path: UNINSTALL_PATH('com.example.todo'), method: 'DELETE' })); + expect( + await screen.findByText(fill(en.marketplace.uninstall.successInList, { manifestId: 'com.example.todo' })), + ).toBeInTheDocument(); + }); + + it('a refusal code the console has no sentence for still reads not loaded and names the code', async () => { + await serve([{ ...REFUSED, notLoaded: { code: 'OS_SOME_LATER_REFUSAL', requiredRange: '^18' } }]); + renderPackages(EN); + + const r = await row('com.example.crm'); + expect(within(r).getByText(en.marketplace.notLoaded.badge)).toBeInTheDocument(); + expect(within(r).getByText(fill(en.marketplace.notLoaded.otherReason, { code: 'OS_SOME_LATER_REFUSAL' }))).toBeInTheDocument(); + expect(r.textContent).not.toContain('^18'); + expect(within(r).getByRole('button', { name: en.marketplace.action.uninstall })).toBeEnabled(); + }); + + it('a walled uninstall refusal shows the door\'s sentence, never a bare status, and the row stays (objectui#7611)', async () => { + const message = + 'Uninstalling "com.example.todo" is refused: the package carries security catalog items. ' + + "Creating, changing or deleting a position requires the platform operator: this deployment runs the 'group' tenancy posture."; + await serve([LOADED], () => + reply(403, { success: false, error: { code: 'FORBIDDEN', message, details: { manifestId: 'com.example.todo' } } }, 'Forbidden'), + ); + renderPackages(EN); + + await uninstallAndConfirm('com.example.todo'); + expect(await screen.findByText(message)).toBeInTheDocument(); + const page = document.body.textContent ?? ''; + expect(page).not.toContain('Forbidden'); + expect(page).not.toContain('HTTP_403'); + expect(screen.queryByText(fill(en.marketplace.uninstall.successInList, { manifestId: 'com.example.todo' }))).toBeNull(); + expect(within(await row('com.example.todo')).getByRole('button', { name: en.marketplace.action.uninstall })).toBeEnabled(); + }); +}); + +describe.each([ + ['en', EN, en as Pack], + ['zh', ZH, zh as unknown as Pack], +])('Packages — the kernel note is the marketplace Uninstall tooltip (objectui#12610) [%s]', (_lang, config, pack) => { + const note = pack.marketplace.installedAdditiveNote; + + it('a list with no marketplace install carries no kernel note', async () => { + await serve([]); + renderPackages(config); + await waitFor(() => expect(calls.some((c) => c.path === '/api/v1/marketplace/install-local')).toBe(true)); + await waitFor(() => expect(document.querySelector('[data-testid^="pkg-row-"]')).toBeNull()); + expect(document.body.textContent).not.toContain(note); + }); + + it('a loaded row\'s Uninstall is described by the note, and hovering it opens a tooltip with it', async () => { + const user = userEvent.setup(); + await serve([LOADED]); + renderPackages(config); + + const uninstall = within(await row('com.example.todo')).getByRole('button', { name: pack.marketplace.action.uninstall }); + expect(uninstall).toHaveAccessibleDescription(note); + const described = document.getElementById(uninstall.getAttribute('aria-describedby') as string); + expect(described).toHaveClass('sr-only'); + + await user.hover(uninstall); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(note); + expect(tooltip.textContent).not.toMatch(/<\/?[a-z]/i); + expect(tooltip.querySelector('strong')).toBeNull(); + }); + + it('a not-loaded row\'s Uninstall carries no note — that package left nothing in the kernel', async () => { + await serve([REFUSED]); + renderPackages(config); + + const uninstall = within(await row('com.example.crm')).getByRole('button', { name: pack.marketplace.action.uninstall }); + expect(uninstall).not.toHaveAttribute('aria-describedby'); + expect(document.body.textContent).not.toContain(note); + }); +}); + +describe('every pack\'s kernel note is plain text (objectui#12610)', () => { + const packs = Object.entries(builtInLocales) as Array<[string, Pack]>; + + it('the walk below reads all ten packs', () => { + expect(packs.map(([lang]) => lang).sort()).toEqual( + ['ar', 'de', 'en', 'es', 'fr', 'ja', 'ko', 'pt', 'ru', 'zh'], + ); + }); + + it.each(packs)('%s: `marketplace.installedAdditiveNote` carries no markup', (_lang, pack) => { + const note = pack.marketplace.installedAdditiveNote; + expect(note.length).toBeGreaterThan(20); + expect(note).not.toMatch(/<\/?[a-z][^>]*>/i); + }); +}); diff --git a/packages/app-shell/src/console/marketplace/__tests__/marketplaceDates.displayLocale-10331.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/marketplaceDates.displayLocale-10331.test.tsx index e26af4680f..25252b4807 100644 --- a/packages/app-shell/src/console/marketplace/__tests__/marketplaceDates.displayLocale-10331.test.tsx +++ b/packages/app-shell/src/console/marketplace/__tests__/marketplaceDates.displayLocale-10331.test.tsx @@ -7,23 +7,24 @@ */ /** - * Two marketplace date faces read the DISPLAY locale, never the UI language + * The marketplace's date face reads the DISPLAY locale, never the UI language * (objectui#10331). * - * - `InstalledList`: the "Installed {{when}}" date was - * `toLocaleString(language || undefined)`, so it spoke the UI language, and - * the machine's locale when the language was empty. * - `MarketplacePage`: a package card's relative published time was * `Intl.RelativeTimeFormat(language || 'en')`, the UI language again, with a * literal `'en'` behind it. * - * Both now take their tag from `useDisplayLocale()`. Every case below mounts + * (The second face this file pinned, the Installed Apps list's "Installed + * {{when}}" line, left with that list's body: objectui#12675 made Setup → + * Packages the one installed list, and its rows draw no install date.) + * + * It now takes its tag from `useDisplayLocale()`. Every case below mounts * the real `I18nProvider` with an ENGLISH UI and declares the display locale * through `LocalizationProvider`, so the only thing that differs between a * `de-CH` session and an `en-US` one is the display locale. A face that still * read `language` renders the English form under `de-CH` and goes red. * - * Each face carries: the `de-CH` pin (the literal form, plus equality with the + * The face carries: the `de-CH` pin (the literal form, plus equality with the * runtime's own `de-CH` rendering), the `en-US` control, the differential * between the two (a runner locale cannot satisfy both), and the runtime * tripwire over the argument the formatter actually received. @@ -47,9 +48,6 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({ useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), })); -/** Noon UTC, so the calendar date is the same in every zone the suite could run in (it pins `TZ=UTC`). */ -const INSTALLED_AT = '2020-03-04T12:00:00.000Z'; - /** Three whole days before the render, so the relative face is "N days ago" and never the translated "today". */ const PUBLISHED = vi.hoisted(() => ({ at: '' })); @@ -86,7 +84,6 @@ vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => { vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: () => {} }) })); import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; -import { InstalledList } from '../InstalledListWidget'; import { MarketplacePage } from '../MarketplacePage'; beforeEach(async () => { @@ -123,14 +120,6 @@ function underSession(locale: string, face: React.ReactElement) { ); } -/** The installed card's "Installed …" line, under display locale `locale`. */ -async function installedLine(locale: string): Promise { - underSession(locale, ); - const text = (await screen.findByText(/^Installed /)).textContent ?? ''; - cleanup(); - return text; -} - /** The catalog card's relative published time, under display locale `locale`. */ async function relativeLine(locale: string): Promise { underSession(locale, ); @@ -141,36 +130,6 @@ async function relativeLine(locale: string): Promise { return text; } -describe('InstalledList — the install date follows the display locale (objectui#10331)', () => { - it('formats as de-CH under an English UI with a de-CH display locale', async () => { - const text = await installedLine('de-CH'); - // The sentence is the English UI's; the date inside it is de-CH's. - expect(text, `got: ${text}`).toMatch(/^Installed /); - expect(text, `got: ${text}`).toMatch(/4\.3\.2020/); - expect(text).toBe(`Installed ${new Date(INSTALLED_AT).toLocaleString('de-CH')}`); - }); - - it('control: formats as en-US under an en-US display locale', async () => { - const text = await installedLine('en-US'); - expect(text, `got: ${text}`).toMatch(/3\/4\/2020/); - expect(text).toBe(`Installed ${new Date(INSTALLED_AT).toLocaleString('en-US')}`); - }); - - it('is a reading of the session, not of the machine', async () => { - expect(await installedLine('de-CH')).not.toBe(await installedLine('en-US')); - }); - - it('the formatter receives the declared tag, never the machine locale', async () => { - const calls = await recordLocaleArgumentsAsync(async () => { - await installedLine('de-CH'); - }); - const dates = calls.filter((c) => c.api === 'Date.prototype.toLocaleString'); - expect(dates.length, `saw: ${JSON.stringify(calls).slice(0, 2000)}`).toBeGreaterThan(0); - expect(dates.every((c) => c.locale === 'de-CH'), `saw: ${JSON.stringify(dates)}`).toBe(true); - expect(calls.filter(isMachineLocale), 'these call sites formatted in the machine locale').toEqual([]); - }); -}); - describe('MarketplacePage — the relative published time follows the display locale (objectui#10331)', () => { it('formats as de-CH under an English UI with a de-CH display locale', async () => { const text = await relativeLine('de-CH'); diff --git a/packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx index c8c0e989eb..f4f64b5210 100644 --- a/packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx +++ b/packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx @@ -32,8 +32,10 @@ * - upgrade: the package page's Reinstall over an installed OLDER version. On * an install-local runtime that is the console's upgrade: it names no * version, so the door installs `latest` over the installed one; - * - uninstall: the package page's local menu, and Installed Apps' row button - * (`uninstallLocal`); + * - uninstall: the package page's local menu (`uninstallLocal`); the same + * refusal on a Setup → Packages marketplace row, which carries the button + * the Installed Apps list had, is pinned in + * `MarketplaceRowActions.installedAppsPins.test.tsx` (objectui#12675); * - install from the catalog page's "Your organization" section (`installLocal` * with `versionId: 'latest'`). * @@ -79,7 +81,6 @@ vi.mock('../../../components/SuggestedBindingsPanel', () => ({ SuggestedBindings import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; import { MarketplacePackagePage } from '../MarketplacePackagePage'; import { MarketplacePage } from '../MarketplacePage'; -import { InstalledList } from '../InstalledListWidget'; type Posture = 'group' | 'isolated'; type Verb = 'Installing' | 'Uninstalling'; @@ -267,17 +268,6 @@ describe.each(['group', 'isolated'])( expect(screen.queryByText('«marketplace.uninstall.successInDetail»')).not.toBeInTheDocument(); }); - it('uninstall: Installed Apps\' row button shows the door\'s `Uninstalling` refusal, and the row stays', async () => { - await boot({ installed: [INSTALLED_OLDER], refusal: () => refused('Uninstalling', posture) }); - render(); - fireEvent.click(await screen.findByRole('button', { name: '«marketplace.action.uninstall»' })); - - await expectReasonShown('Uninstalling', posture); - expect(writes).toEqual([{ method: 'DELETE', path: UNINSTALL_PATH, body: undefined }]); - expect(screen.queryByText('«marketplace.uninstall.successInList»')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '«marketplace.action.uninstall»' })).toBeEnabled(); - }); - it('install: the catalog page\'s organization install shows the door\'s `Installing` refusal', async () => { await boot({ installed: [], refusal: () => refused('Installing', posture) }); render(); diff --git a/packages/app-shell/src/console/marketplace/marketplaceApi.ts b/packages/app-shell/src/console/marketplace/marketplaceApi.ts index 4fd74da91c..3cd140a2f5 100644 --- a/packages/app-shell/src/console/marketplace/marketplaceApi.ts +++ b/packages/app-shell/src/console/marketplace/marketplaceApi.ts @@ -878,16 +878,31 @@ export async function installLocal(input: { return (payload?.data ?? payload) as LocalInstallResult; } +/** + * `GET /api/v1/marketplace/install-local`, strictly: a failed request or an + * answer with no `items` array throws instead of reading as "nothing + * installed". The Packages page's Source column reads its marketplace half + * from this (objectui#12675), where an empty answer would silently relabel + * every marketplace install as `code`. + */ +export async function fetchLocalInstalls(): Promise { + const res = await fetch(`${API_BASE}/install-local`, { + credentials: 'include', + headers: { 'Accept': 'application/json' }, + }); + const payload: any = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(readApiError(payload, res).message); + const items = payload?.data?.items ?? payload?.items; + if (!Array.isArray(items)) { + throw new Error(`GET ${API_BASE}/install-local answered without an items list.`); + } + return items as LocalInstallEntry[]; +} + +/** {@link fetchLocalInstalls}, with any failure read as an empty list. */ export async function listLocalInstalls(): Promise { try { - const res = await fetch(`${API_BASE}/install-local`, { - credentials: 'include', - headers: { 'Accept': 'application/json' }, - }); - if (!res.ok) return []; - const payload: any = await res.json().catch(() => ({})); - const items = payload?.data?.items ?? payload?.items ?? []; - return Array.isArray(items) ? (items as LocalInstallEntry[]) : []; + return await fetchLocalInstalls(); } catch { return []; } @@ -939,6 +954,19 @@ export async function listInstalledPackages(): Promise } } +/** + * What the marketplace slice lists as installed — the read the Installed Apps + * list made, now the marketplace half of the Packages page's Source column + * (objectui#12675). `cloud` = the control plane's list (a cloud-connected + * environment: installs by any path); `local` = this runtime's install-local + * ledger. The local leg is strict ({@link fetchLocalInstalls}). + */ +export async function readMarketplaceInstalls(): Promise<{ source: 'cloud' | 'local'; items: LocalInstallEntry[] }> { + const cloud = await listInstalledPackages(); + if (cloud.connected) return { source: 'cloud', items: cloud.items }; + return { source: 'local', items: await fetchLocalInstalls() }; +} + export async function uninstallLocal(manifestId: string): Promise { const res = await fetch(`${API_BASE}/install-local/${encodeURIComponent(manifestId)}`, { method: 'DELETE', diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx index 2ddefe72b9..4fef067b0e 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx @@ -38,6 +38,10 @@ const PKG: InstalledPackageRow = { manifest: { id: PKG_ID, name: PKG_NAME, version: '1.0.0', type: 'app' }, enabled: true, status: 'installed', + // Every served row carries the server's writability verdict + // (objectstack#14375); the Packages list's Source column refuses a row + // without it (objectui#12675). + writable: true, }; const DRAFTS = [ { type: 'object', name: 'acme_lead' }, diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.source-12675.test.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.source-12675.test.tsx new file mode 100644 index 0000000000..9739e2588b --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.source-12675.test.tsx @@ -0,0 +1,304 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → Packages is the one list of what is installed (objectui#12675, + * ruling B on objectstack#23892). + * + * - Every package carries a Source from the closed set code / marketplace / + * authored, read from the row's `writable` verdict joined with the + * marketplace slice's installed list (`packageSource.ts`). + * - With the marketplace capability (`features.installLocal`) present, the + * rows whose Source is marketplace carry the Installed Apps actions (Details, + * Uninstall); with it absent, no row does and nothing reads the marketplace. + * - `?source=VALUE` filters the list; an unknown or repeated value, and a row + * whose origin the signals cannot place, are refused by name and nothing is + * drawn in their place. + * - The `marketplace:installed-list` widget (the plugin's Installed Apps page) + * and the legacy `system/marketplace/installed` route open Packages filtered + * to Source = marketplace. + * + * The wire is stubbed (`fetch`), the runtime config is the real module booted + * over a served body, and `marketplaceApi` is not mocked. + */ + +import '@testing-library/jest-dom/vitest'; +import type * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; + +const admin = vi.hoisted(() => ({ value: true })); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: admin.value, isResolved: true }), +})); + +import { PackagesPage, type InstalledPackageRow } from './PackagesPage'; +import { t, tFormat } from './i18n'; +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config'; +import '../../console/marketplace/InstalledListWidget'; +import { MarketplaceInstalledPage } from '../../console/marketplace/MarketplaceInstalledPage'; + +const CODE: InstalledPackageRow = { + manifest: { id: 'com.acme.code', name: 'Code App', version: '1.0.0', scope: 'project' }, + enabled: true, + writable: false, +}; +const MARKET: InstalledPackageRow = { + manifest: { id: 'com.example.todo', name: 'Todo', version: '1.0.0' }, + enabled: true, + writable: false, +}; +const AUTHORED: InstalledPackageRow = { + manifest: { id: 'app.b2r4', name: 'My Base' }, + enabled: true, + writable: true, +}; +const LEDGER_TODO = { + packageId: 'com.example.todo', + versionId: 'ver_todo_1', + manifestId: 'com.example.todo', + version: '1.0.0', + installedAt: '2026-10-05T09:00:00.000Z', + withSampleData: true, +}; + +interface Served { + packages: unknown[]; + ledger?: unknown[]; + /** Status the install-local listing answers with. */ + ledgerStatus?: number; +} + +let calls: Array<{ path: string; method: string }> = []; + +const reply = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +/** Boot the real runtime config with `installLocal`, then serve the package doors. */ +async function serve(installLocal: boolean, served: Served) { + resetRuntimeConfigForTesting(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => reply(200, { features: { installLocal } })), + ); + await initRuntimeConfig(); + calls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: { method?: string }) => { + const path = new URL(String(input), 'http://console.test').pathname; + const method = (init?.method ?? 'GET').toUpperCase(); + calls.push({ path, method }); + if (path === '/api/v1/packages') return reply(200, { success: true, data: { packages: served.packages } }); + // Not cloud-bound: the marketplace slice reads the local ledger. + if (path === '/api/v1/cloud-connection/installed') return reply(404, {}); + if (path === '/api/v1/marketplace/install-local' && method === 'GET') { + const status = served.ledgerStatus ?? 200; + return status === 200 + ? reply(200, { success: true, data: { items: served.ledger ?? [] } }) + : reply(status, { success: false, error: { code: 'INTERNAL_ERROR', message: 'ledger unreadable' } }); + } + return reply(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } }); + }), + ); +} + +function LocationProbe() { + const { pathname, search } = useLocation(); + return
{`${pathname}${search}`}
; +} + +const PACKAGES_PATH = '/apps/setup/component/developer/packages'; + +function renderPackages(search = '') { + return render( + + + + , + ); +} + +/** id → the Source the row draws, for every drawn row. */ +async function drawnSources(): Promise> { + await screen.findAllByTestId('pkg-source'); + const out: Record = {}; + for (const row of screen.getAllByTestId(/^pkg-row-(?!uninstall|details)/)) { + const id = row.getAttribute('data-testid')!.slice('pkg-row-'.length); + out[id] = within(row).getByTestId('pkg-source').getAttribute('data-source'); + } + return out; +} + +afterEach(() => { + cleanup(); + admin.value = true; + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); +}); + +describe('Packages — marketplace capability present (objectui#12675)', () => { + it('every package carries its Source, and only the marketplace row carries the actions', async () => { + await serve(true, { packages: [CODE, MARKET, AUTHORED], ledger: [LEDGER_TODO] }); + renderPackages(); + + // The kernel toggle hides nothing here: no row has a platform scope. + expect(await drawnSources()).toEqual({ + 'com.acme.code': 'code', + 'com.example.todo': 'marketplace', + 'app.b2r4': 'authored', + }); + expect(screen.getByTestId('pkg-row-com.example.todo')).toContainElement( + screen.getByTestId('pkg-row-uninstall-com.example.todo'), + ); + expect(screen.getByTestId('pkg-row-details-com.example.todo')).toBeInTheDocument(); + for (const id of ['com.acme.code', 'app.b2r4']) { + expect(within(screen.getByTestId(`pkg-row-${id}`)).queryByRole('button')).toBeNull(); + } + }); + + it('`?source=marketplace` shows only the marketplace rows, and Show all clears it', async () => { + await serve(true, { packages: [CODE, MARKET, AUTHORED], ledger: [LEDGER_TODO] }); + renderPackages('?source=marketplace'); + + expect(await drawnSources()).toEqual({ 'com.example.todo': 'marketplace' }); + fireEvent.click(within(screen.getByTestId('pkg-source-filter')).getByRole('button')); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent(new RegExp(`^${PACKAGES_PATH}$`))); + expect(Object.keys(await drawnSources())).toHaveLength(3); + }); + + it('Uninstall confirms in-app and removes the install through the install-local door', async () => { + await serve(true, { packages: [CODE, MARKET], ledger: [LEDGER_TODO] }); + renderPackages(); + + fireEvent.click(await screen.findByTestId('pkg-row-uninstall-com.example.todo')); + fireEvent.click(await screen.findByTestId('pkg-row-uninstall-confirm-com.example.todo')); + await waitFor(() => + expect(calls).toContainEqual({ path: '/api/v1/marketplace/install-local/com.example.todo', method: 'DELETE' }), + ); + // A click on a row action never opens the row's detail sheet. + expect(screen.queryByRole('dialog', { name: 'Todo' })).toBeNull(); + }); + + it('Details opens the package\'s marketplace page, where Update and the sample-data menu live', async () => { + await serve(true, { packages: [MARKET], ledger: [LEDGER_TODO] }); + renderPackages(); + + fireEvent.click(await screen.findByTestId('pkg-row-details-com.example.todo')); + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('/apps/setup/system/marketplace/com.example.todo'), + ); + }); + + it('a viewer who is not a workspace admin sees every Source and no marketplace action', async () => { + admin.value = false; + await serve(true, { packages: [CODE, MARKET, AUTHORED], ledger: [LEDGER_TODO] }); + renderPackages(); + + expect(await drawnSources()).toEqual({ + 'com.acme.code': 'code', + 'com.example.todo': 'marketplace', + 'app.b2r4': 'authored', + }); + expect(screen.queryByTestId('pkg-row-uninstall-com.example.todo')).toBeNull(); + expect(screen.queryByTestId('pkg-row-details-com.example.todo')).toBeNull(); + }); +}); + +describe('Packages — marketplace capability absent (objectui#12675)', () => { + it('every package still carries its Source, no row carries a marketplace action, and the marketplace is not read', async () => { + await serve(false, { packages: [CODE, MARKET, AUTHORED], ledger: [LEDGER_TODO] }); + renderPackages(); + + // Nothing is a marketplace install where the capability is absent. + expect(await drawnSources()).toEqual({ + 'com.acme.code': 'code', + 'com.example.todo': 'code', + 'app.b2r4': 'authored', + }); + expect(screen.queryByText(t('engine.packages.col.actions', 'en'))).toBeNull(); + expect(screen.queryByTestId(/^pkg-row-(uninstall|details)-/)).toBeNull(); + expect(calls.some((c) => c.path.startsWith('/api/v1/marketplace/'))).toBe(false); + }); +}); + +describe('Packages — an origin or filter the Source boundary cannot place is refused (objectui#12675)', () => { + it('a row with no `writable` verdict refuses the list by its id; no row is drawn', async () => { + const { writable: _absent, ...noVerdict } = CODE; + await serve(true, { packages: [noVerdict, AUTHORED], ledger: [] }); + renderPackages(); + + expect(await screen.findByTestId('packages-load-error')).toHaveTextContent( + tFormat('engine.packages.source.unknown', 'en', { id: 'com.acme.code' }), + ); + expect(screen.queryAllByTestId('pkg-source')).toHaveLength(0); + }); + + it('a writable row the marketplace also lists is a conflict, refused by its id', async () => { + await serve(true, { packages: [AUTHORED], ledger: [{ ...LEDGER_TODO, packageId: 'app.b2r4', manifestId: 'app.b2r4' }] }); + renderPackages(); + + expect(await screen.findByTestId('packages-load-error')).toHaveTextContent( + tFormat('engine.packages.source.conflict', 'en', { id: 'app.b2r4' }), + ); + expect(screen.queryAllByTestId('pkg-source')).toHaveLength(0); + }); + + it('a marketplace listing that cannot be read refuses the list instead of drawing its installs as code', async () => { + await serve(true, { packages: [CODE, MARKET], ledgerStatus: 500 }); + renderPackages(); + + expect(await screen.findByTestId('packages-load-error')).toHaveTextContent('ledger unreadable'); + expect(screen.queryAllByTestId('pkg-source')).toHaveLength(0); + }); + + it.each([ + ['an unknown value', '?source=plugin', 'engine.packages.source.filterUnknown', 'plugin'], + ['an empty value', '?source=', 'engine.packages.source.filterUnknown', ''], + ['a repeated param', '?source=code&source=authored', 'engine.packages.source.filterRepeated', 'code, authored'], + ])('%s in `?source=` is refused by name, and no row is drawn', async (_label, search, key, value) => { + await serve(true, { packages: [CODE, MARKET, AUTHORED], ledger: [LEDGER_TODO] }); + renderPackages(search); + + expect(await screen.findByTestId('packages-source-filter-error')).toHaveTextContent( + tFormat(key, 'en', { value }), + ); + await waitFor(() => expect(calls.some((c) => c.path === '/api/v1/packages')).toBe(true)); + expect(screen.queryAllByTestId('pkg-source')).toHaveLength(0); + }); +}); + +describe('The Installed Apps entries open Packages filtered to Source = marketplace (objectui#12675)', () => { + it('the `marketplace:installed-list` widget still resolves, and lands on the filtered Packages URL', () => { + const Widget = ComponentRegistry.get('marketplace:installed-list') as unknown as React.ComponentType; + expect(Widget).toBeTypeOf('function'); + render( + + + + } /> + + + , + ); + expect(screen.getByTestId('location')).toHaveTextContent(`${PACKAGES_PATH}?source=marketplace`); + }); + + it('the legacy `system/marketplace/installed` route lands on the same URL, keeping the app segment', () => { + render( + + + + } /> + + + , + ); + expect(screen.getByTestId('location')).toHaveTextContent( + '/apps/crm/component/developer/packages?source=marketplace', + ); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index ade4d28740..ce7d2ebd26 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -17,7 +17,7 @@ */ import * as React from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { Package as PackageIcon, Plus, @@ -78,6 +78,17 @@ import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; import { refusedItemsText } from '../../preview/publishRefusal.js'; import { duplicatePackage, isSpecPackageId } from '../studio-design/packages-io.js'; import { PackageIdInput } from '../studio-design/PackageIdInput.js'; +import { useWorkspaceAdminStatus } from '@object-ui/auth'; +import { getRuntimeConfig } from '../../runtime-config.js'; +import { readMarketplaceInstalls, type LocalInstallEntry } from '../../console/marketplace/marketplaceApi.js'; +import { MarketplaceNotLoaded, MarketplaceRowActions } from '../../console/marketplace/MarketplaceRowActions.js'; +import { + PACKAGES_SOURCE_PARAM, + PackageSourceRefusal, + readSourceFilter, + resolvePackageSource, + type PackageSource, +} from './packageSource.js'; /* -------------------------------------------------------------------------- */ /* Types + API */ @@ -129,6 +140,11 @@ export interface InstalledPackageRow { enabled?: boolean; statusChangedAt?: string; errorMessage?: string; + /** + * The server's writability verdict, a top-level key every row carries + * (objectstack#14375). The Source column reads it — see `packageSource.ts`. + */ + writable?: boolean; } const API = '/api/v1/packages'; @@ -206,6 +222,48 @@ function StatusBadge({ pkg }: { pkg: InstalledPackageRow }) { ); } +const SOURCE_BADGE_CLASS: Record = { + code: '', + marketplace: 'border-sky-500/40 text-sky-700 dark:text-sky-300', + authored: 'border-emerald-500/40 text-emerald-700 dark:text-emerald-300', +}; + +/** One of the closed Source values (objectui#12675); never anything else. */ +function SourceBadge({ source }: { source: PackageSource }) { + const locale = useMetadataLocale(); + return ( + + {t(`engine.packages.source.${source}`, locale)} + + ); +} + +/** A Source refusal, in the page's words (see `packageSource.ts`). */ +function sourceRefusalText(e: PackageSourceRefusal, locale: string): string { + switch (e.code) { + case 'PACKAGE_SOURCE_UNKNOWN': + return tFormat('engine.packages.source.unknown', locale, { id: e.subject }); + case 'PACKAGE_SOURCE_CONFLICT': + return tFormat('engine.packages.source.conflict', locale, { id: e.subject }); + case 'PACKAGE_SOURCE_FILTER_UNKNOWN': + return tFormat('engine.packages.source.filterUnknown', locale, { value: e.subject }); + case 'PACKAGE_SOURCE_FILTER_REPEATED': + return tFormat('engine.packages.source.filterRepeated', locale, { value: e.subject }); + } +} + +/** + * One line of the list: a registry package with its Source, or a marketplace + * install the runtime did not load (it has no registry row, but it is + * installed, and Uninstall acts on it — objectui#11645). + */ +interface PackageListRow { + id: string; + pkg: InstalledPackageRow | null; + source: PackageSource; + install: LocalInstallEntry | null; +} + /* -------------------------------------------------------------------------- */ /* Create-package dialog */ /* -------------------------------------------------------------------------- */ @@ -1019,12 +1077,36 @@ export function PackagesPage() { const [importMsg, setImportMsg] = React.useState<{ kind: 'ok' | 'err'; text: string } | null>( null, ); + // objectui#12675 — every row's Source, and the marketplace slice's list. + const [rows, setRows] = React.useState([]); + const [marketplaceList, setMarketplaceList] = React.useState<'cloud' | 'local'>('local'); + const [marketplaceMsg, setMarketplaceMsg] = React.useState<{ ok: boolean; message: string } | null>(null); + // The marketplace capability is the install-local plugin, which the runtime + // reports as `features.installLocal` — the signal the marketplace package + // page reads. Off ⇒ no marketplace read, no marketplace rows, no actions. + const marketplaceOn = getRuntimeConfig().features.installLocal; + const { isAdmin } = useWorkspaceAdminStatus(); + const showMarketplaceActions = marketplaceOn && isAdmin; + // The Source filter is URL state (`?source=`), one closed spelling; an + // unknown or repeated value is refused below, not ignored. + const [searchParams, setSearchParams] = useSearchParams(); + const sourceFilter: { value: PackageSource | null; refusal: string | null } = (() => { + try { + return { value: readSourceFilter(searchParams.getAll(PACKAGES_SOURCE_PARAM)), refusal: null }; + } catch (e) { + if (e instanceof PackageSourceRefusal) return { value: null, refusal: sourceRefusalText(e, locale) }; + throw e; + } + })(); const load = React.useCallback(async () => { setLoading(true); setError(null); try { - const data = await apiJson<{ packages: InstalledPackageRow[] }>(API); + const [data, marketplace] = await Promise.all([ + apiJson<{ packages: InstalledPackageRow[] }>(API), + marketplaceOn ? readMarketplaceInstalls() : Promise.resolve(null), + ]); const list = Array.isArray(data?.packages) ? data.packages : []; list.sort((a, b) => { // User (project) packages first, then by name. @@ -1033,13 +1115,33 @@ export function PackagesPage() { if (sa !== sb) return sa - sb; return (a.manifest.name || a.manifest.id).localeCompare(b.manifest.name || b.manifest.id); }); + const installs = new Map((marketplace?.items ?? []).map((i) => [i.manifestId, i])); + const ids = new Set(installs.keys()); + const next: PackageListRow[] = list.map((p) => ({ + id: p.manifest.id, + pkg: p, + source: resolvePackageSource(p, ids), + install: installs.get(p.manifest.id) ?? null, + })); + const loaded = new Set(list.map((p) => p.manifest.id)); + for (const install of installs.values()) { + if (!loaded.has(install.manifestId)) { + next.push({ id: install.manifestId, pkg: null, source: 'marketplace', install }); + } + } setPackages(list); + setRows(next); + setMarketplaceList(marketplace?.source ?? 'local'); } catch (e: any) { - setError(e?.message ?? t('engine.packages.loadFailed', locale)); + setError( + e instanceof PackageSourceRefusal + ? sourceRefusalText(e, locale) + : (e?.message ?? t('engine.packages.loadFailed', locale)), + ); } finally { setLoading(false); } - }, [locale]); + }, [locale, marketplaceOn]); React.useEffect(() => { void load(); @@ -1054,16 +1156,23 @@ export function PackagesPage() { const filtered = React.useMemo(() => { const q = query.trim().toLowerCase(); - return packages.filter((p) => { - const kernel = p.manifest.scope === 'system' || p.manifest.scope === 'cloud'; + return rows.filter((r) => { + if (sourceFilter.value && r.source !== sourceFilter.value) return false; + const kernel = r.pkg?.manifest.scope === 'system' || r.pkg?.manifest.scope === 'cloud'; if (kernel && !showKernel) return false; if (!q) return true; return ( - p.manifest.id.toLowerCase().includes(q) || - (p.manifest.name || '').toLowerCase().includes(q) + r.id.toLowerCase().includes(q) || + (r.pkg?.manifest.name || '').toLowerCase().includes(q) ); }); - }, [packages, query, showKernel]); + }, [rows, query, showKernel, sourceFilter.value]); + + const clearSourceFilter = () => { + const next = new URLSearchParams(searchParams); + next.delete(PACKAGES_SOURCE_PARAM); + setSearchParams(next, { replace: true }); + }; const openDetail = (pkg: InstalledPackageRow) => { setSelected(pkg); @@ -1174,6 +1283,17 @@ export function PackagesPage() { onChange={(e) => setQuery(e.target.value)} /> + {sourceFilter.value && ( +
+ + {t('engine.packages.col.source', locale)}: + + + +
+ )} {kernelCount > 0 && (
@@ -1184,9 +1304,27 @@ export function PackagesPage() { )}
+ {marketplaceMsg && ( +
+ {marketplaceMsg.message} +
+ )} + {/* Body */}
- {loading ? ( + {sourceFilter.refusal ? ( +
+ + {sourceFilter.refusal} +
+ ) : loading ? (
{[0, 1, 2].map((i) => ( @@ -1205,7 +1343,7 @@ export function PackagesPage() { {t('engine.packages.empty', locale)} - {packages.length === 0 + {rows.length === 0 ? t('engine.packages.emptyCreate', locale) : t('engine.packages.emptyFiltered', locale)} @@ -1220,26 +1358,53 @@ export function PackagesPage() { {t('engine.packages.col.version', locale)} {t('engine.packages.col.scope', locale)} {t('engine.packages.col.status', locale)} + {t('engine.packages.col.source', locale)} + {showMarketplaceActions && ( + {t('engine.packages.col.actions', locale)} + )} - {filtered.map((p) => ( + {filtered.map(({ id: rowId, pkg: p, source, install }) => ( openDetail(p)} + key={rowId} + className={p ? 'cursor-pointer' : undefined} + onClick={p ? () => openDetail(p) : undefined} + data-testid={`pkg-row-${rowId}`} > - {p.manifest.name || p.manifest.id} + {p?.manifest.name || rowId} - {p.manifest.id} + {rowId} + + {p?.manifest.version || install?.version || '—'} + + {p ? : '—'} - {p.manifest.version || '—'} - + {install?.notLoaded ? ( + + ) : p ? ( + + ) : ( + '—' + )} - + + {showMarketplaceActions && ( + + {source === 'marketplace' && install && ( + void load()} + /> + )} + + )} ))} diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx index 681d96d4ac..1634575963 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx @@ -43,6 +43,10 @@ const PKG: InstalledPackageRow = { manifest: { id: PKG_ID, name: PKG_NAME, version: '1.0.0', type: 'app' }, enabled: true, status: 'installed', + // Every served row carries the server's writability verdict + // (objectstack#14375); the Packages list's Source column refuses a row + // without it (objectui#12675). + writable: true, }; /** `walledCatalogWriteVerdict`'s refusal sentence (`@objectstack/metadata-core` at `4806b18aea`), for a position. */ diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index fa6889ace7..5bf93b85d4 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2267,6 +2267,16 @@ const ENGINE_STRINGS_EN: Record = { 'engine.packages.scope.system': 'System', 'engine.packages.scope.cloud': 'Cloud', 'engine.packages.scope.writable': 'Writable', + 'engine.packages.col.source': 'Source', + 'engine.packages.col.actions': 'Actions', + 'engine.packages.source.code': 'Code', + 'engine.packages.source.marketplace': 'Marketplace', + 'engine.packages.source.authored': 'Authored', + 'engine.packages.source.showAll': 'Show all', + 'engine.packages.source.unknown': 'Package "{id}" was refused: the server did not say whether it is writable, so its source (code, marketplace or authored) cannot be told.', + 'engine.packages.source.conflict': 'Package "{id}" was refused: the server marks it writable while the marketplace lists it as installed, so its source cannot be told.', + 'engine.packages.source.filterUnknown': 'Unknown source filter "{value}". Use code, marketplace or authored.', + 'engine.packages.source.filterRepeated': 'The source filter is given more than once ({value}). Give one of code, marketplace or authored.', 'engine.packages.status.enabled': 'Enabled', 'engine.packages.status.disabled': 'Disabled', 'engine.packages.import.invalidJson': 'Selected file is not valid JSON.', @@ -5630,6 +5640,16 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.packages.scope.system': '系统', 'engine.packages.scope.cloud': '云端', 'engine.packages.scope.writable': '可写', + 'engine.packages.col.source': '来源', + 'engine.packages.col.actions': '操作', + 'engine.packages.source.code': '代码', + 'engine.packages.source.marketplace': '应用市场', + 'engine.packages.source.authored': '自建', + 'engine.packages.source.showAll': '显示全部', + 'engine.packages.source.unknown': '软件包「{id}」已被拒绝:服务器未说明它是否可写,因此无法判断其来源(代码、应用市场或自建)。', + 'engine.packages.source.conflict': '软件包「{id}」已被拒绝:服务器标记它可写,而应用市场将它列为已安装,因此无法判断其来源。', + 'engine.packages.source.filterUnknown': '未知的来源筛选值「{value}」。请使用 code、marketplace 或 authored。', + 'engine.packages.source.filterRepeated': '来源筛选给出了不止一次({value})。请只给出 code、marketplace 或 authored 之一。', 'engine.packages.status.enabled': '已启用', 'engine.packages.status.disabled': '已禁用', 'engine.packages.import.invalidJson': '所选文件不是有效 JSON。', diff --git a/packages/app-shell/src/views/metadata-admin/packageSource.ts b/packages/app-shell/src/views/metadata-admin/packageSource.ts new file mode 100644 index 0000000000..9b8d01d5d7 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/packageSource.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where an installed package came from — the Packages page's Source column + * and its Source filter (objectui#12675, ruling B on objectstack#23892). + * + * Setup's Packages is the ONE list of what is installed, so each row says + * which of three origins it has. The set is CLOSED: `code` / `marketplace` / + * `authored`, and nothing else is ever drawn. A row whose origin cannot be + * read from the signals below is refused at this boundary with a named code, + * never rendered with an empty cell. + * + * ## Each value traces to one named signal + * + * `/api/v1/packages` serves no source key (`InstalledPackageSchema` in + * `@objectstack/spec/kernel` declares none). Two reads the console already + * makes answer it between them: + * + * - `writable`, the top-level verdict every `/api/v1/packages` row carries + * (objectstack#14375, ADR-0070 D2). The server computes it with + * `isWritablePackage`: `false` for a package registered from an artifact + * (`engine.manifests` — a booted code package, AND a marketplace install, + * which install-local registers through the same `registerApp`) or with a + * platform scope; `true` for a database-authored base. So `true` ⇒ + * `authored`. `studio-design/packages-io` reads the same verdict for the + * Studio switcher; this module reads it strictly — an absent or non-boolean + * value is a refusal here, not the switcher's scope heuristic. + * - the marketplace slice's own installed list — the read the Installed Apps + * list made (`readMarketplaceInstalls`: the control plane's list when the + * environment is cloud-connected, the runtime's install-local ledger + * otherwise). A non-writable row whose id is in it ⇒ `marketplace`; one that + * is not ⇒ `code`. + * + * A row that is BOTH writable and in the marketplace list contradicts the + * server's own verdict (a marketplace install is never writable), so it is + * refused too rather than resolved by picking a side. + * + * ⚠️ `writable` is served and pinned by objectstack's dispatcher + * (`packages-serializable-response.test.ts`) but is not declared on the spec's + * response schema; the contract-first form of this read is a declared key on + * the served row, which is the objectstack half named in this card's report. + */ + +/** The closed set of origins, in the order the filter and the column list them. */ +export const PACKAGE_SOURCES = ['code', 'marketplace', 'authored'] as const; + +export type PackageSource = (typeof PACKAGE_SOURCES)[number]; + +/** + * The Packages page's Source filter, spelled as ONE URL query param. + * + * `/apps/APP/component/developer/packages?source=marketplace` opens the list + * filtered; a Setup nav item reaches the same URL by declaring + * `{ type: 'component', componentRef: 'developer:packages', params: { source: 'marketplace' } }`, + * because the sidebar forwards a component item's `params` as the query string. + * Page-local, so it lives here and not in the console's reserved `urlParams`. + */ +export const PACKAGES_SOURCE_PARAM = 'source'; + +export type PackageSourceRefusalCode = + /** The row carries no boolean `writable` verdict, so its origin is unknown. */ + | 'PACKAGE_SOURCE_UNKNOWN' + /** The row is writable AND listed by the marketplace: the signals disagree. */ + | 'PACKAGE_SOURCE_CONFLICT' + /** `?source=` names a value outside {@link PACKAGE_SOURCES}. */ + | 'PACKAGE_SOURCE_FILTER_UNKNOWN' + /** `?source=` appears more than once. */ + | 'PACKAGE_SOURCE_FILTER_REPEATED'; + +/** A refusal at the Source boundary, naming what it refused. */ +export class PackageSourceRefusal extends Error { + readonly code: PackageSourceRefusalCode; + /** The package id, or the filter value(s), the refusal is about. */ + readonly subject: string; + + constructor(code: PackageSourceRefusalCode, subject: string) { + super(`${code}: ${subject}`); + this.name = 'PackageSourceRefusal'; + this.code = code; + this.subject = subject; + } +} + +export function isPackageSource(value: unknown): value is PackageSource { + return typeof value === 'string' && (PACKAGE_SOURCES as readonly string[]).includes(value); +} + +/** + * The origin of one `/api/v1/packages` row. Throws {@link PackageSourceRefusal} + * when the signals cannot name one. + * + * @param marketplaceIds manifest ids the marketplace slice lists as installed; + * empty when the marketplace capability is absent (nothing is a marketplace + * install there). + */ +export function resolvePackageSource( + row: { manifest: { id: string }; writable?: unknown }, + marketplaceIds: ReadonlySet, +): PackageSource { + const id = row.manifest.id; + if (typeof row.writable !== 'boolean') { + throw new PackageSourceRefusal('PACKAGE_SOURCE_UNKNOWN', id); + } + const listed = marketplaceIds.has(id); + if (row.writable) { + if (listed) throw new PackageSourceRefusal('PACKAGE_SOURCE_CONFLICT', id); + return 'authored'; + } + return listed ? 'marketplace' : 'code'; +} + +/** + * The Source filter the URL asks for: `null` when absent. Throws + * {@link PackageSourceRefusal} for a value outside the closed set or a repeated + * param — a filter that silently matched nothing (or everything) would read as + * a served answer. + * + * @param values every occurrence of {@link PACKAGES_SOURCE_PARAM} + * (`URLSearchParams.getAll`). + */ +export function readSourceFilter(values: readonly string[]): PackageSource | null { + if (values.length === 0) return null; + if (values.length > 1) { + throw new PackageSourceRefusal('PACKAGE_SOURCE_FILTER_REPEATED', values.join(', ')); + } + const [value] = values; + if (!isPackageSource(value)) { + throw new PackageSourceRefusal('PACKAGE_SOURCE_FILTER_UNKNOWN', value); + } + return value; +} + +/** The Packages route, optionally filtered to one Source. */ +export function packagesHref(basePath: string, source?: PackageSource): string { + const path = `${basePath}/component/developer/packages`; + return source ? `${path}?${PACKAGES_SOURCE_PARAM}=${source}` : path; +} diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 7fffd8dd5d..aafa006bdf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3791,15 +3791,8 @@ const ar = { noMatchFilters: "لا توجد تطبيقات تطابق الفلاتر.", noDescription: "لا وصف متاح.", back: "العودة إلى السوق", - installedTitle: "التطبيقات المثبتة", - installedSubtitle: "حزم السوق المثبتة حالياً في نواة هذا الوقت التشغيل.", - installedEmpty: "لا توجد تطبيقات سوق مثبتة في هذا الوقت التشغيل بعد.", browseLink: "استعراض السوق →", installedAdditiveNote: "واجهة برمجة النواة إضافية فقط — إلغاء التثبيت يزيل المانيفست من القرص لكي لا يتم تحميل الحزمة عند التشغيل التالي، لكن النواة الجارية تبقي التطبيق مسجلاً حتى إعادة التشغيل.", - installedAt: "تم التثبيت {{when}}", - installedBy: "بواسطة {{user}}", - installedPackageId: "الحزمة", - cachedAs: "مخزن مؤقتاً كـ {{path}}", versionBadge: "v{{version}}", installedBadge: "مثبت v{{version}}", // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 26d42d94ae..034599d9f1 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3512,15 +3512,8 @@ const de = { noMatchFilters: "Keine Apps entsprechen Ihren Filtern.", noDescription: "Keine Beschreibung vorhanden.", back: "Zurück zum Marktplatz", - installedTitle: "Installierte Apps", - installedSubtitle: "Marktplatz-Pakete, die derzeit im Kernel dieses Laufzeitsystems installiert sind.", - installedEmpty: "Noch keine Marktplatz-Apps in diesem Laufzeitsystem installiert.", browseLink: "Marktplatz durchsuchen →", installedAdditiveNote: "Die Kernel-API ist nur additiv — Deinstallation entfernt das Manifest auf der Festplatte, damit das Paket beim nächsten Start nicht geladen wird, aber der laufende Kernel hält die App bis zum Neustart registriert.", - installedAt: "Installiert {{when}}", - installedBy: "von {{user}}", - installedPackageId: "Paket", - cachedAs: "Zwischengespeichert als {{path}}", versionBadge: "v{{version}}", installedBadge: "Installiert v{{version}}", // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 07f57744fc..525a503d52 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -4169,15 +4169,8 @@ const en = { noMatchFilters: 'No apps match your filters.', noDescription: 'No description provided.', back: 'Back to marketplace', - installedTitle: 'Installed Apps', - installedSubtitle: 'Marketplace packages currently installed into this runtime\'s kernel. Cached manifests live in .objectstack/installed-packages/ and survive restarts.', - installedEmpty: 'No marketplace apps installed in this runtime yet.', browseLink: 'Browse the marketplace →', installedAdditiveNote: 'The kernel API is additive only — uninstall removes the on-disk manifest so the package won\'t load on next boot, but the running kernel keeps the app registered until you restart the runtime.', - installedAt: 'Installed {{when}}', - installedBy: 'by {{user}}', - installedPackageId: 'package', - cachedAs: 'Cached as {{path}}', versionBadge: 'v{{version}}', installedBadge: 'Installed v{{version}}', // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 034442f20e..29de57cdfb 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3580,15 +3580,8 @@ const es = { noMatchFilters: "Ninguna aplicación coincide con sus filtros.", noDescription: "Sin descripción disponible.", back: "Volver al marketplace", - installedTitle: "Aplicaciones instaladas", - installedSubtitle: "Paquetes del marketplace instalados en el kernel de este runtime.", - installedEmpty: "Aún no hay aplicaciones del marketplace instaladas en este runtime.", browseLink: "Explorar marketplace →", installedAdditiveNote: "La API del kernel es solo aditiva — desinstalar elimina el manifiesto del disco para que el paquete no se cargue al próximo inicio, pero el kernel en ejecución mantiene la aplicación registrada hasta reiniciar.", - installedAt: "Instalado {{when}}", - installedBy: "por {{user}}", - installedPackageId: "Paquete", - cachedAs: "Almacenado en caché como {{path}}", versionBadge: "v{{version}}", installedBadge: "Instalado v{{version}}", // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 0397c96f00..0fdd005ff3 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3577,15 +3577,8 @@ const fr = { noMatchFilters: "Aucune application ne correspond à vos filtres.", noDescription: "Aucune description disponible.", back: "Retour au marketplace", - installedTitle: "Applications installées", - installedSubtitle: "Paquets du marketplace actuellement installés dans le noyau de ce runtime.", - installedEmpty: "Aucune application du marketplace installée dans ce runtime pour l'instant.", browseLink: "Parcourir le marketplace →", installedAdditiveNote: "L'API du noyau est uniquement additive — la désinstallation supprime le manifeste du disque pour que le paquet ne se charge pas au prochain démarrage, mais le noyau en cours d'exécution maintient l'application enregistrée jusqu'au redémarrage.", - installedAt: "Installé {{when}}", - installedBy: "par {{user}}", - installedPackageId: "Paquet", - cachedAs: "Mis en cache sous {{path}}", versionBadge: "v{{version}}", installedBadge: "Installé v{{version}}", // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 920e437db7..fdccd26345 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3514,15 +3514,8 @@ const ja = { noMatchFilters: "フィルターに一致するアプリがありません。", noDescription: "説明がありません。", back: "マーケットプレイスに戻る", - installedTitle: "インストール済みアプリ", - installedSubtitle: "このランタイムのカーネルにインストールされているマーケットプレイスパッケージ。", - installedEmpty: "このランタイムにはまだマーケットプレイスアプリがインストールされていません。", browseLink: "マーケットプレイスを参照 →", installedAdditiveNote: "カーネルAPIは追加のみです。アンインストールするとディスク上のマニフェストが削除され、次回起動時にパッケージは読み込まれませんが、実行中のカーネルはランタイムを再起動するまでアプリを登録したままにします。", - installedAt: "{{when}} にインストール", - installedBy: "{{user}} による", - installedPackageId: "パッケージ", - cachedAs: "{{path}} としてキャッシュ", versionBadge: "v{{version}}", installedBadge: "インストール済み v{{version}}", // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 00b2113691..1835d118bb 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3511,15 +3511,8 @@ const ko = { noMatchFilters: "필터와 일치하는 앱이 없습니다.", noDescription: "사용 가능한 설명이 없습니다.", back: "마켓플레이스로 돌아가기", - installedTitle: "설치된 앱", - installedSubtitle: "이 런타임의 커널에 현재 설치된 마켓플레이스 패키지.", - installedEmpty: "아직 이 런타임에 마켓플레이스 앱이 설치되지 않았습니다.", browseLink: "마켓플레이스 탐색 →", installedAdditiveNote: "커널 API는 추가 전용입니다 — 제거하면 디스크에서 매니페스트가 삭제되어 다음 시작 시 패키지가 로드되지 않지만, 실행 중인 커널은 재시작될 때까지 앱을 등록된 상태로 유지합니다.", - installedAt: "{{when}} 설치됨", - installedBy: "{{user}}에 의해", - installedPackageId: "패키지", - cachedAs: "{{path}}로 캐시됨", versionBadge: "v{{version}}", installedBadge: "v{{version}} 설치됨", // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index edfbf5e8eb..cd3763453a 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3575,15 +3575,8 @@ const pt = { noMatchFilters: "Nenhum aplicativo corresponde aos seus filtros.", noDescription: "Sem descrição disponível.", back: "Voltar ao marketplace", - installedTitle: "Aplicativos instalados", - installedSubtitle: "Pacotes do marketplace atualmente instalados no kernel deste runtime.", - installedEmpty: "Nenhum aplicativo do marketplace instalado neste runtime ainda.", browseLink: "Explorar marketplace →", installedAdditiveNote: "A API do kernel é apenas aditiva — desinstalar remove o manifesto do disco para que o pacote não seja carregado na próxima inicialização, mas o kernel em execução mantém o aplicativo registrado até reiniciar.", - installedAt: "Instalado {{when}}", - installedBy: "por {{user}}", - installedPackageId: "Pacote", - cachedAs: "Armazenado em cache como {{path}}", versionBadge: "v{{version}}", installedBadge: "Instalado v{{version}}", // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 50e431d921..1baaec825b 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3664,15 +3664,8 @@ const ru = { noMatchFilters: "Нет приложений, соответствующих фильтрам.", noDescription: "Описание отсутствует.", back: "Назад в маркетплейс", - installedTitle: "Установленные приложения", - installedSubtitle: "Пакеты маркетплейса, установленные в ядре этого рантайма.", - installedEmpty: "Пока нет установленных приложений.", browseLink: "Маркетплейс →", installedAdditiveNote: "API ядра только аддитивный — удаление убирает манифест с диска, чтобы пакет не загружался при следующем запуске, но работающее ядро держит приложение зарегистрированным до перезапуска.", - installedAt: "Установлено {{when}}", - installedBy: "пользователем {{user}}", - installedPackageId: "Пакет", - cachedAs: "Кэшировано как {{path}}", versionBadge: "v{{version}}", installedBadge: "Установлено v{{version}}", // objectui#11645 — an install-local entry this runtime refused to load at diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 76ad64b510..98eed176a6 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3637,15 +3637,8 @@ const zh = { noMatchFilters: '没有应用匹配当前筛选条件。', noDescription: '未提供描述。', back: '返回应用市场', - installedTitle: '已安装的应用', - installedSubtitle: '当前已安装到本运行时内核的应用市场软件包。缓存的清单文件位于 .objectstack/installed-packages/,重启后仍然保留。', - installedEmpty: '本运行时尚未安装任何应用市场应用。', browseLink: '去应用市场看看 →', installedAdditiveNote: '内核 API 仅支持新增 — 卸载会移除磁盘上的清单,下次启动时不再加载;但运行中的内核仍会保留该应用,直到下次重启运行时才会真正卸载。', - installedAt: '安装于 {{when}}', - installedBy: '由 {{user}}', - installedPackageId: '包', - cachedAs: '缓存为 {{path}}', versionBadge: 'v{{version}}', installedBadge: '已安装 v{{version}}', // objectui#11645 — an install-local entry this runtime refused to load at