diff --git a/.changeset/30325-install-confirm-what-it-adds.md b/.changeset/30325-install-confirm-what-it-adds.md new file mode 100644 index 00000000000..55a9bcba569 --- /dev/null +++ b/.changeset/30325-install-confirm-what-it-adds.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(console): a marketplace app's Install confirms what it adds before it installs + +Clause-②: no +Metadata-protocol: none + +- **The confirm.** Install on a marketplace package page (an install into this runtime) now opens a short confirm in the console's own dialog before anything is installed. It says how many objects, permission sets and sample records the package adds, and which permission sets it will suggest granting to all signed-in users (the Everyone position) once installed; nothing is granted until an administrator accepts. Its Install installs exactly as the button did before, so trying an app is still one more click; Cancel installs nothing. Sample data stays on. +- **Where the counts come from.** The package body the install itself installs: the catalog's version manifest the page already reads for the package's install modes, so the confirm adds no request. Objects and permission sets are counted across every package body it carries; sample records are the records of its seed datasets, shown as "up to N" when a dataset is scoped to some environments or locales and may not load here. If the manifest cannot be read, the confirm says so and Install still works; the install result says what loaded. +- **Strings.** `@object-ui/i18n` gains `marketplace.install.preview.*` (title, reinstallTitle, intro, reading, unreadable, objects, permissionSets, sampleRecords, atMost, sampleNote, suggestsEveryone) in all 10 locales. diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index d30a00ce15b..daaa2020f2f 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -68,7 +68,8 @@ import { localizePackage } from './usePackageL10n.js'; import { isNewerVersion } from './versionPrecedence.js'; import { getMarketplacePackage, - getDeclaredInstallModes, + getServedPackageBody, + declaredInstallModesOf, installPackage, installLocal, uninstallLocal, @@ -92,6 +93,7 @@ import { import { useDependencyMissingText } from './dependencyMissing.js'; import { useProtocolCheckSkippedText } from './protocolCheckSkipped.js'; import { ProtocolCheckSkippedNote } from './ProtocolCheckSkippedNote.js'; +import { installPreviewOf, type InstallPreview } from './installPreview.js'; import { forgetSampleData, rememberSampleData, sampleDataNotice, sampleRecordsLoaded, takeClearConfirmRequest } from './sampleDataNotice.js'; import { SampleDataLeaveBanner } from './SampleDataLeaveBanner.js'; import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js'; @@ -299,6 +301,12 @@ export function MarketplacePackagePage() { // to another package never offers or sends the last one's. const [modesRead, setModesRead] = useState<{ packageId: string; modes: readonly LocalInstallMode[] | undefined } | null>(null); const [modePick, setModePick] = useState<{ packageId: string; mode: LocalInstallMode } | null>(null); + // objectstack#30325 — what the install will add, read from the same manifest + // answer as the modes, kept with its package. `preview: null` = the manifest + // could not be read or is not a package body; no entry for this package yet + // = still reading. The confirm in front of a local Install draws it. + const [previewRead, setPreviewRead] = useState<{ packageId: string; preview: InstallPreview | null } | null>(null); + const [confirmLocalOpen, setConfirmLocalOpen] = useState(false); // `undefined` until read, when the manifest declares none, or when it could // not be read: then no choice is drawn and no mode is sent, so the door's own // default and answer decide. @@ -349,10 +357,15 @@ export function MarketplacePackagePage() { let cancelled = false; (async () => { try { - const modes = await getDeclaredInstallModes(packageId); - if (!cancelled) setModesRead({ packageId, modes }); + // One read serves both: the modes it declares, and what it adds. + const body = await getServedPackageBody(packageId); + if (cancelled) return; + setModesRead({ packageId, modes: declaredInstallModesOf(body) }); + setPreviewRead({ packageId, preview: installPreviewOf(body) }); } catch { // No manifest to read: no choice and no mode, and the door decides. + // The confirm says it could not read what the install adds. + if (!cancelled) setPreviewRead({ packageId, preview: null }); } })(); return () => { cancelled = true; }; @@ -615,13 +628,24 @@ export function MarketplacePackagePage() { } }; + /** + * The local Install button: opens the confirm that says what the install + * adds (objectstack#30325). Its Install is the one click that installs, so + * the install itself is unchanged and stays one click away. + */ + const doInstallLocal = () => { + if (!packageId) return; + if (offeredInstallModes && !requestedInstallMode) return; + setConfirmLocalOpen(true); + }; + /** * Install this package into the LOCAL runtime kernel (not a cloud env). - * Single-target operation — no picker, no dialog. Same-origin POST so + * Single-target operation — no environment picker. Same-origin POST so * cloud session is not required. The local AuthPlugin session is what * the backend validates. */ - const doInstallLocal = async () => { + const runInstallLocal = async () => { if (!packageId) return; // ADR-0131 D6 — the mode is sent only when the manifest declares // `installModes`: its one mode, or the operator's pick among several. @@ -1307,6 +1331,7 @@ export function MarketplacePackagePage() { ? t('marketplace.install.mode.required') : undefined; const installModeReasonId = 'install-mode-required-reason'; + const localPreviewRead = previewRead && previewRead.packageId === packageId ? previewRead : null; const primaryAction = supportsLocal ? { label: installingLocal @@ -1589,6 +1614,63 @@ export function MarketplacePackagePage() { + {/* objectstack#30325 — the confirm in front of a local Install: what the + install adds, read from the manifest it installs, and the grants it + will suggest. Its Install runs the install as the button did. */} + + + + + {localInstall + ? t('marketplace.install.preview.reinstallTitle', { name: loc.displayName }) + : t('marketplace.install.preview.title', { name: loc.displayName })} + + + {!localPreviewRead + ? t('marketplace.install.preview.reading') + : localPreviewRead.preview + ? t('marketplace.install.preview.intro') + : t('marketplace.install.preview.unreadable')} + + + {localPreviewRead?.preview && ( +
+
+
{t('marketplace.install.preview.objects')}
+
{localPreviewRead.preview.objects}
+
{t('marketplace.install.preview.permissionSets')}
+
{localPreviewRead.preview.permissionSets}
+
{t('marketplace.install.preview.sampleRecords')}
+
+ {localPreviewRead.preview.sampleRecordsAtMost + ? t('marketplace.install.preview.atMost', { count: localPreviewRead.preview.sampleRecords }) + : localPreviewRead.preview.sampleRecords} +
+
+ {localPreviewRead.preview.sampleRecords > 0 && ( +

{t('marketplace.install.preview.sampleNote')}

+ )} + {localPreviewRead.preview.suggestedGrants.length > 0 && ( +
    + {localPreviewRead.preview.suggestedGrants.map((set) => ( +
  • {t('marketplace.install.preview.suggestsEveryone', { set })}
  • + ))} +
+ )} +
+ )} + + {t('common.cancel')} + { void runInstallLocal(); }} + > + {localInstall ? t('marketplace.action.reinstall') : t('marketplace.action.install')} + + +
+
+ { setInstallOpen(o); if (!o) { setInstallResult(null); setAcknowledged(false); } }}> { diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.credentialStep-27238.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.credentialStep-27238.test.tsx index 77f592ee9dc..49552e413ed 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.credentialStep-27238.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.credentialStep-27238.test.tsx @@ -118,6 +118,8 @@ async function install() { const button = await screen.findByRole('button', { name: '«marketplace.action.install»' }); await act(async () => {}); fireEvent.click(button); + // objectstack#30325 — Install opens the confirm; its Install installs. + fireEvent.click(await screen.findByTestId('install-preview-confirm')); } beforeEach(() => { diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx index 79da87e5cb2..0066c35f7e6 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.dependencyMissing-25792.test.tsx @@ -92,6 +92,8 @@ async function install() { const button = await screen.findByRole('button', { name: '«marketplace.action.install»' }); await act(async () => {}); fireEvent.click(button); + // objectstack#30325 — Install opens the confirm; its Install installs. + fireEvent.click(await screen.findByTestId('install-preview-confirm')); } const refusal = (details: unknown) => ({ diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installMode-12288.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installMode-12288.test.tsx index 94a4b45a479..56c81f670db 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installMode-12288.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installMode-12288.test.tsx @@ -186,6 +186,8 @@ const radio = (mode: 'managed' | 'unmanaged') => async function install() { fireEvent.click(installButton()); + // objectstack#30325 — Install opens the confirm; its Install installs. + fireEvent.click(await screen.findByTestId('install-preview-confirm')); await waitFor(() => expect(posts).toHaveLength(1)); } diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installPreview-30325.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installPreview-30325.test.tsx new file mode 100644 index 00000000000..38762c93bd6 --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installPreview-30325.test.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The package page's local Install opens a confirm that says what the install + * adds before anything is installed (objectstack#30325): how many objects, + * permission sets and sample records, and which permission sets it will + * suggest granting to Everyone. Its Install is the one click that installs. + * + * The counts come from the manifest answer the page already reads for the + * install modes (`GET /api/v1/marketplace/packages/:id/versions/latest/manifest`, + * the body install-local installs): one read serves both. + * + * `marketplaceApi` is NOT mocked: one stubbed `fetch` answers by path, so the + * requests counted are the ones the page puts on the wire. `t` echoes the KEY + * and the interpolated name, count and set. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; + +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ packageId: 'pkg_crm' }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, o?: Record) => { + const facts = ['name', 'count', 'set'].filter((k) => o?.[k] !== undefined).map((k) => `${k}=${String(o?.[k])}`); + return facts.length > 0 ? `«${key}|${facts.join('|')}»` : `«${key}»`; + }, + language: 'en', + }), +})); + +vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} })); +vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: async () => {} }) })); +vi.mock('../../../components/SuggestedBindingsPanel', () => ({ + SuggestedBindingsPanel: () =>
, +})); +vi.mock('../ConnectorCredentialsStep', () => ({ + ConnectorCredentialsStep: () =>
, +})); + +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; +import { MarketplacePackagePage } from '../MarketplacePackagePage'; + +const PKG = 'app.acme.crm'; +const INSTALL_PATH = '/api/v1/marketplace/install-local'; +const MANIFEST_PATH = '/api/v1/marketplace/packages/pkg_crm/versions/latest/manifest'; +const CATALOG_PACKAGE = { + package: { id: 'pkg_crm', manifest_id: PKG, display_name: 'Acme CRM', latest_version: { id: 'ver_6', version: '6.0.0' } }, + versions: [{ id: 'ver_6', version: '6.0.0' }], +}; +const records = (n: number) => Array.from({ length: n }, (_, i) => ({ name: `r${i}` })); + +/** A flattened marketplace bundle, the shape a published app's version serves. */ +const BUNDLE = { + manifest: { id: PKG, version: '6.0.0', type: 'app', name: 'Acme CRM' }, + objects: [{ name: 'crm_account' }, { name: 'crm_contact' }, { name: 'crm_lead' }], + permissions: [{ name: 'crm_user', isDefault: true }, { name: 'crm_admin' }], + data: [ + { object: 'crm_account', externalId: 'name', records: records(5) }, + { object: 'crm_contact', externalId: 'email', records: records(4) }, + ], +}; + +const MANAGED = { manifestId: PKG, version: '6.0.0', versionId: 'ver_6', installedAt: '2026-11-05T00:00:00Z', upgradedFrom: null, hotLoaded: true }; + +const answer = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + statusText: '', + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), +}); + +let calls: string[] = []; + +async function boot(manifest: { status: number; body?: unknown }) { + calls = []; + resetRuntimeConfigForTesting(); + 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(`${method} ${path}`); + if (path === '/api/v1/runtime/config') { + return answer(200, { + cloudUrl: '', singleEnvironment: true, + features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true }, + branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' }, + }); + } + if (path === INSTALL_PATH && method === 'GET') return answer(200, { success: true, data: { items: [], total: 0 } }); + if (path === INSTALL_PATH && method === 'POST') return answer(200, { success: true, data: MANAGED }); + if (path === MANIFEST_PATH) { + return manifest.status === 200 + ? answer(200, { success: true, data: { manifest: manifest.body, version: '6.0.0', version_id: 'ver_6' } }) + : answer(manifest.status, { success: false, error: { code: 'NOT_FOUND', message: 'no manifest' } }); + } + if (path === '/api/v1/marketplace/packages/pkg_crm') return answer(200, { success: true, data: CATALOG_PACKAGE }); + return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } }); + })); + await initRuntimeConfig(); +} + +const posts = () => calls.filter((c) => c === `POST ${INSTALL_PATH}`); + +/** Render, settle the manifest read, and click the page's Install. */ +async function openConfirm() { + render(); + const button = await screen.findByRole('button', { name: '«marketplace.action.install»' }); + await waitFor(() => expect(calls).toContain(`GET ${MANIFEST_PATH}`)); + await act(async () => {}); + fireEvent.click(button); + return screen.findByTestId('install-preview-dialog'); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); +}); + +describe('the local Install confirms what it adds first (objectstack#30325)', () => { + it('Install opens the confirm with the counts and the suggested grant, and installs nothing yet', async () => { + await boot({ status: 200, body: BUNDLE }); + const dialog = await openConfirm(); + + expect(within(dialog).getByText('«marketplace.install.preview.title|name=Acme CRM»')).toBeInTheDocument(); + expect(within(dialog).getByText('«marketplace.install.preview.intro»')).toBeInTheDocument(); + expect(within(dialog).getByTestId('install-preview-objects')).toHaveTextContent('3'); + expect(within(dialog).getByTestId('install-preview-permission-sets')).toHaveTextContent('2'); + expect(within(dialog).getByTestId('install-preview-sample-records')).toHaveTextContent('9'); + expect(within(dialog).getByText('«marketplace.install.preview.sampleNote»')).toBeInTheDocument(); + expect(within(dialog).getByTestId('install-preview-suggestions')).toHaveTextContent( + '«marketplace.install.preview.suggestsEveryone|set=crm_user»', + ); + expect(posts()).toHaveLength(0); + // One manifest read served the modes and the counts. + expect(calls.filter((c) => c === `GET ${MANIFEST_PATH}`)).toHaveLength(1); + }); + + it('its Install installs once, as the button did, and the result is drawn', async () => { + await boot({ status: 200, body: BUNDLE }); + await openConfirm(); + fireEvent.click(screen.getByTestId('install-preview-confirm')); + + expect(await screen.findByText('«marketplace.install.localSuccess|name=Acme CRM»')).toBeInTheDocument(); + expect(posts()).toHaveLength(1); + expect(screen.queryByTestId('install-preview-dialog')).not.toBeInTheDocument(); + }); + + it('Cancel installs nothing', async () => { + await boot({ status: 200, body: BUNDLE }); + const dialog = await openConfirm(); + fireEvent.click(within(dialog).getByRole('button', { name: '«common.cancel»' })); + + await waitFor(() => expect(screen.queryByTestId('install-preview-dialog')).not.toBeInTheDocument()); + await act(async () => {}); + expect(posts()).toHaveLength(0); + }); + + it('a scoped sample dataset reads as an upper bound', async () => { + await boot({ status: 200, body: { ...BUNDLE, data: [{ object: 'crm_account', locale: ['zh-CN'], records: records(6) }] } }); + const dialog = await openConfirm(); + + expect(within(dialog).getByTestId('install-preview-sample-records')).toHaveTextContent('«marketplace.install.preview.atMost|count=6»'); + }); + + it('no sample records and no default set: no sample note and no suggestion line', async () => { + await boot({ status: 200, body: { manifest: BUNDLE.manifest, objects: BUNDLE.objects, permissions: [{ name: 'crm_admin' }] } }); + const dialog = await openConfirm(); + + expect(within(dialog).getByTestId('install-preview-sample-records')).toHaveTextContent('0'); + expect(within(dialog).queryByText('«marketplace.install.preview.sampleNote»')).not.toBeInTheDocument(); + expect(within(dialog).queryByTestId('install-preview-suggestions')).not.toBeInTheDocument(); + }); + + it('a manifest that cannot be read: the confirm says so, and its Install still installs', async () => { + await boot({ status: 404 }); + const dialog = await openConfirm(); + + expect(within(dialog).getByText('«marketplace.install.preview.unreadable»')).toBeInTheDocument(); + expect(within(dialog).queryByTestId('install-preview')).not.toBeInTheDocument(); + fireEvent.click(within(dialog).getByTestId('install-preview-confirm')); + await waitFor(() => expect(posts()).toHaveLength(1)); + }); +}); diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.protocolCheckSkipped-30326.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.protocolCheckSkipped-30326.test.tsx index ecea7dca144..687f6b13cc7 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.protocolCheckSkipped-30326.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.protocolCheckSkipped-30326.test.tsx @@ -98,6 +98,8 @@ async function install() { const button = await screen.findByRole('button', { name: '«marketplace.action.install»' }); await act(async () => {}); fireEvent.click(button); + // objectstack#30325 — Install opens the confirm; its Install installs. + fireEvent.click(await screen.findByTestId('install-preview-confirm')); } const MANAGED = { manifestId: 'com.acme.psa', version: '1.0.0', versionId: 'ver_1', installedAt: '2026-11-05T00:00:00Z', upgradedFrom: null }; diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.sampleDataNotice-30323.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.sampleDataNotice-30323.test.tsx index dbb67571e6d..ab20b65eac5 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.sampleDataNotice-30323.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.sampleDataNotice-30323.test.tsx @@ -127,6 +127,8 @@ async function install(withProvider = false) { const button = await screen.findByRole('button', { name: /«marketplace\.action\.(install|reinstall)»/ }); await act(async () => {}); fireEvent.click(button); + // objectstack#30325 — Install opens the confirm; its Install installs. + fireEvent.click(await screen.findByTestId('install-preview-confirm')); return view; } diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.servedTruth-12098.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.servedTruth-12098.test.tsx index 63c27aeab7e..df205ad1929 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.servedTruth-12098.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.servedTruth-12098.test.tsx @@ -276,6 +276,8 @@ describe('install-local reads hotLoaded (objectui#12098)', () => { await bootConsole({ installLocal: true }); await renderPage(); fireEvent.click(await screen.findByRole('button', { name: '«marketplace.action.install»' })); + // objectstack#30325 — Install opens the confirm; its Install installs. + fireEvent.click(await screen.findByTestId('install-preview-confirm')); } it('a 200 with hotLoaded: false says the app loads at the next restart, and refreshes no app list', async () => { diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/installPreview-30325.test.ts b/ui/packages/app-shell/src/console/marketplace/__tests__/installPreview-30325.test.ts new file mode 100644 index 00000000000..ebf0dbabae2 --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/installPreview-30325.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `installPreviewOf` counts what a served package body adds the way the + * install door reads that body (objectstack#30325): every package body of the + * shapes the door accepts, `objects` as a list or a map, `permissions` only as + * a LIST of sets (the manifest-stage capability grant is an object), sample + * records from the top-level `data[]` the seed step loads, and each + * `isDefault: true` set as an Everyone suggestion. + */ + +import { describe, expect, it } from 'vitest'; +import { installPreviewOf } from '../installPreview'; + +const records = (n: number) => Array.from({ length: n }, (_, i) => ({ name: `r${i}` })); + +describe('installPreviewOf (objectstack#30325)', () => { + it('a flattened bundle: manifest nested, collections at the top', () => { + expect(installPreviewOf({ + manifest: { id: 'app.acme.crm', version: '6.0.0', type: 'app', permissions: { network: { hosts: ['api.acme.test'] } } }, + objects: [{ name: 'crm_account' }, { name: 'crm_contact' }, { name: 'crm_lead' }], + permissions: [ + { name: 'crm_user', label: 'CRM User', isDefault: true }, + { name: 'crm_admin', label: 'CRM Admin' }, + ], + data: [ + { object: 'crm_account', externalId: 'name', records: records(4) }, + { object: 'crm_contact', externalId: 'email', records: records(3) }, + ], + })).toEqual({ objects: 3, permissionSets: 2, sampleRecords: 7, sampleRecordsAtMost: false, suggestedGrants: ['crm_user'] }); + }); + + it('a flat body whose only `permissions` is the capability grant counts no permission set', () => { + expect(installPreviewOf({ + id: 'com.acme.tool', version: '1.0.0', type: 'plugin', + permissions: { network: { hosts: ['x.test'] } }, + objects: { tool_item: { label: 'Item' }, tool_tag: { label: 'Tag' } }, + })).toEqual({ objects: 2, permissionSets: 0, sampleRecords: 0, sampleRecordsAtMost: false, suggestedGrants: [] }); + }); + + it('a multi-package artifact: objects and sets of every package body', () => { + expect(installPreviewOf({ + manifest: { id: 'app.acme.suite', version: '1.0.0', type: 'app' }, + packages: [ + { manifest: { id: 'app.acme.suite', type: 'app', objects: [{ name: 'a_one' }], permissions: [{ name: 'suite_user', isDefault: true }] } }, + { manifest: { id: 'app.acme.suite.extra', type: 'module', objects: [{ name: 'b_one' }, { name: 'b_two' }] } }, + ], + })).toEqual({ objects: 3, permissionSets: 1, sampleRecords: 0, sampleRecordsAtMost: false, suggestedGrants: ['suite_user'] }); + }); + + it('a dataset scoped by environment or locale makes the count an upper bound', () => { + const env = installPreviewOf({ id: 'x', data: [{ object: 'o', env: ['dev'], records: records(2) }, { object: 'p', records: records(1) }] }); + expect(env).toMatchObject({ sampleRecords: 3, sampleRecordsAtMost: true }); + const locale = installPreviewOf({ id: 'x', data: [{ object: 'o', locale: ['zh-CN'], records: records(2) }] }); + expect(locale).toMatchObject({ sampleRecords: 2, sampleRecordsAtMost: true }); + const everywhere = installPreviewOf({ id: 'x', data: [{ object: 'o', env: ['prod', 'dev', 'test'], records: records(2) }] }); + expect(everywhere).toMatchObject({ sampleRecords: 2, sampleRecordsAtMost: false }); + }); + + it('skips data entries the seed step skips: no object, or no records list', () => { + expect(installPreviewOf({ id: 'x', data: [{ records: records(5) }, { object: 'o' }, { object: 'p', records: records(1) }] })) + .toMatchObject({ sampleRecords: 1 }); + }); + + it('not a package body: null, so the confirm says it could not read it', () => { + expect(installPreviewOf(undefined)).toBeNull(); + expect(installPreviewOf('nope')).toBeNull(); + expect(installPreviewOf([])).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx index f4f64b5210c..6e8ec8c5964 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx @@ -237,6 +237,8 @@ describe.each(['group', 'isolated'])( await boot({ installed: [], refusal: () => refused('Installing', posture) }); render(); fireEvent.click(await screen.findByRole('button', { name: '«marketplace.action.install»' })); + // objectstack#30325 — Install opens the confirm; its Install installs. + fireEvent.click(await screen.findByTestId('install-preview-confirm')); await expectReasonShown('Installing', posture); expect(writes).toEqual([{ method: 'POST', path: INSTALL_PATH, body: { packageId: PKG } }]); @@ -250,6 +252,7 @@ describe.each(['group', 'isolated'])( // The local install is listed (3.2.0) and the catalog's latest is 4.0.1: the // page's one install control now installs `latest` over it. fireEvent.click(await screen.findByRole('button', { name: '«marketplace.action.reinstall»' })); + fireEvent.click(await screen.findByTestId('install-preview-confirm')); await expectReasonShown('Installing', posture); expect(writes).toEqual([{ method: 'POST', path: INSTALL_PATH, body: { packageId: PKG } }]); @@ -294,6 +297,7 @@ describe('CONTROL: the bare forms are detectable on this harness (objectui#7611) }); render(); fireEvent.click(await screen.findByRole('button', { name: '«marketplace.action.install»' })); + fireEvent.click(await screen.findByTestId('install-preview-confirm')); await waitFor(() => expect(writes).toHaveLength(1)); await waitFor(() => { diff --git a/ui/packages/app-shell/src/console/marketplace/installPreview.ts b/ui/packages/app-shell/src/console/marketplace/installPreview.ts new file mode 100644 index 00000000000..a434218797f --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/installPreview.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What a marketplace package adds to this runtime, read BEFORE the install + * (objectstack#30325), so the confirm in front of Install can say it. + * + * The source is the package body the install door itself installs: the + * catalog's `GET /packages/:id/versions/latest/manifest`, the read the package + * page already makes for the package's install modes. Nothing here is a second + * opinion of the door's: each count follows the door's own reading of that + * body, and the install's answer (its `seeded` report, objectstack#30323) stays + * what says what was loaded. + * + * - Package bodies: a compiled artifact nests its manifest under `manifest:` + * beside the collections, a flattened bundle carries both at the top, and a + * multi-package artifact carries each package's body in a `packages[]` + * entry (ADR-0130 D4). The door registers every one of them. + * - Objects: each body's `objects`, a list or a map keyed by name. + * - Permission sets: each body's `permissions` LIST. The same key at the + * manifest stage is the capability grant (ADR-0025), an object, which is not + * a set and is not counted. + * - Sample records: the body's top-level `data[]` entries that name an object + * and carry `records`, summed: the datasets the install's seed step loads. + * A dataset scoped to some environments (`env`) or locales (`locale`) may + * not load here, so then the count is an upper bound (`sampleRecordsAtMost`). + * - Suggested grants: each permission set declaring `isDefault: true`, which + * the install turns into a suggestion to grant it to the Everyone position + * (ADR-0090 D5). Nothing is granted until an administrator accepts it. + */ + +/** What the confirm says a package adds. */ +export interface InstallPreview { + objects: number; + permissionSets: number; + sampleRecords: number; + /** A counted dataset is scoped by environment or locale: `sampleRecords` is at most what loads. */ + sampleRecordsAtMost: boolean; + /** Permission-set names the install will suggest granting to Everyone. */ + suggestedGrants: string[]; +} + +const ALL_ENVS = ['prod', 'dev', 'test']; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** One package body, flat: a nested `manifest:` lifted beside the collections, the collections winning. */ +function flatten(body: Record): Record { + if (!isRecord(body.manifest)) return body; + const { manifest, ...sections } = body; + return { ...manifest, ...sections }; +} + +function objectCount(objects: unknown): number { + if (Array.isArray(objects)) return objects.filter(isRecord).length; + if (isRecord(objects)) return Object.values(objects).filter(isRecord).length; + return 0; +} + +function permissionSetsOf(permissions: unknown): Record[] { + return Array.isArray(permissions) + ? permissions.filter((p): p is Record => isRecord(p) && typeof p.name === 'string' && p.name.length > 0) + : []; +} + +function scoped(dataset: Record): boolean { + if (Array.isArray(dataset.locale) && dataset.locale.length > 0) return true; + if (Array.isArray(dataset.env)) return !ALL_ENVS.every((e) => dataset.env.includes(e)); + return false; +} + +/** + * The preview of the served manifest value (`data.manifest` of the catalog's + * manifest answer). `null` when it is not a package body, so the confirm says + * it could not read what the package adds rather than saying it adds nothing. + */ +export function installPreviewOf(served: unknown): InstallPreview | null { + if (!isRecord(served)) return null; + const top = flatten(served); + const bodies: Record[] = [top]; + if (Array.isArray(top.packages)) { + for (const entry of top.packages) { + if (isRecord(entry) && isRecord(entry.manifest)) bodies.push(flatten(entry.manifest)); + } + } + + let objects = 0; + let permissionSets = 0; + const suggestedGrants: string[] = []; + for (const body of bodies) { + objects += objectCount(body.objects); + const sets = permissionSetsOf(body.permissions); + permissionSets += sets.length; + for (const set of sets) { + if (set.isDefault === true && !suggestedGrants.includes(set.name)) suggestedGrants.push(set.name); + } + } + + const datasets = Array.isArray(top.data) + ? top.data.filter((d: unknown): d is Record => isRecord(d) && !!d.object && Array.isArray(d.records)) + : []; + const sampleRecords = datasets.reduce((sum: number, d: Record) => sum + d.records.length, 0); + const sampleRecordsAtMost = datasets.some((d: Record) => d.records.length > 0 && scoped(d)); + + return { objects, permissionSets, sampleRecords, sampleRecordsAtMost, suggestedGrants }; +} diff --git a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts index 5cfc7f78c12..7374676672f 100644 --- a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts +++ b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts @@ -280,19 +280,33 @@ const LOCAL_INSTALL_MODES: readonly LocalInstallMode[] = ['managed', 'unmanaged' * - Otherwise each declared mode once: a mode named twice changes nothing * (the spec's reading of the key). */ +export function declaredInstallModesOf(body: unknown): readonly LocalInstallMode[] | undefined { + if (!isPlainRecord(body)) return undefined; + const declared = (isPlainRecord(body.manifest) ? body.manifest : body).installModes; + if (!Array.isArray(declared) || declared.length === 0) return undefined; + if (!declared.every((m) => (LOCAL_INSTALL_MODES as readonly unknown[]).includes(m))) return undefined; + return LOCAL_INSTALL_MODES.filter((m) => declared.includes(m)); +} + +/** {@link declaredInstallModesOf} the manifest a package version serves. */ export async function getDeclaredInstallModes( packageId: string, versionId = 'latest', ): Promise { + return declaredInstallModesOf(await getServedPackageBody(packageId, versionId)); +} + +/** + * The package body a version's manifest answer carries (`data.manifest`): the + * body install-local fetches and installs when it is given this package and + * version. The package page reads it ONCE, for the install modes it declares + * and for what the install will add (objectstack#30325, `installPreview.ts`). + */ +export async function getServedPackageBody(packageId: string, versionId = 'latest'): Promise { const data = await call<{ manifest?: unknown } | null>( `/packages/${encodeURIComponent(packageId)}/versions/${encodeURIComponent(versionId)}/manifest`, ); - const body = data?.manifest; - if (!isPlainRecord(body)) return undefined; - const declared = (isPlainRecord(body.manifest) ? body.manifest : body).installModes; - if (!Array.isArray(declared) || declared.length === 0) return undefined; - if (!declared.every((m) => (LOCAL_INSTALL_MODES as readonly unknown[]).includes(m))) return undefined; - return LOCAL_INSTALL_MODES.filter((m) => declared.includes(m)); + return data?.manifest; } /** diff --git a/ui/packages/i18n/src/__tests__/count-families-11445.test.ts b/ui/packages/i18n/src/__tests__/count-families-11445.test.ts index 223635b071e..4c319eb8c32 100644 --- a/ui/packages/i18n/src/__tests__/count-families-11445.test.ts +++ b/ui/packages/i18n/src/__tests__/count-families-11445.test.ts @@ -130,6 +130,9 @@ const COUNT_NEUTRAL = { 'element.metadataViewer.steps', 'element.metadataViewer.hiddenSteps', 'element.metadataViewer.objectAccess', + // objectstack#30325 — the install confirm's "Sample records: up to N" + // value; the noun is the row's label, the number stands after "up to". + 'marketplace.install.preview.atMost', ], /** Every pack phrases it count-invariantly: an `(s)` marker in `en`/`de`/ * `fr`/`es`/`pt` (the convention `perm-home-namespace-3546.test.tsx` pins), diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 3a682b6e67b..1ff97c419d6 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4609,6 +4609,21 @@ const ar = { unmanagedSuccess: "تم نسخ v{{version}} من \"{{name}}\" إلى وقت التشغيل هذا كبيانات وصفية خاصة به، في القاعدة {{base}}. النسخة قابلة للتحرير ولا تتلقى أي ترقية.", unmanagedStrippedLocks: "كانت الحزمة تقفل هذه العناصر. النسخة قابلة للتحرير، لذلك لم تُنسخ أقفالها:", unmanagedIgnored: "لا يدعم وقت التشغيل هذا التثبيت غير المُدار، لذلك ثُبّت \"{{name}}\" كحزمة مُدارة (مسجّلة كتعليمات برمجية) بدلاً من نسخه. رقِّ وقت التشغيل، ثم ألغِ تثبيت الحزمة وثبّتها مرة أخرى كنسخة غير مُدارة.", + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "تثبيت {{name}}؟", + reinstallTitle: "إعادة تثبيت {{name}}؟", + intro: "يضيف التثبيت ما يلي إلى وقت التشغيل هذا:", + reading: "جارٍ قراءة ما تضيفه هذه الحزمة…", + unreadable: "تعذّرت قراءة ما تضيفه هذه الحزمة قبل التثبيت. ستذكر نتيجة التثبيت ما تم تحميله.", + objects: "الكائنات", + permissionSets: "مجموعات الأذونات", + sampleRecords: "السجلات التجريبية", + atMost: "حتى {{count}}", + sampleNote: "تُحمَّل السجلات التجريبية مع التطبيق. يمكنك حذفها من هذه الصفحة بعد التثبيت.", + suggestsEveryone: "بعد التثبيت سيقترح منح «{{set}}» لجميع المستخدمين المسجّلين (موضع Everyone). لن يُمنح أي شيء حتى توافق.", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 9bdc5109dfc..09fd1ef297e 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4256,6 +4256,21 @@ const de = { unmanagedSuccess: "v{{version}} von „{{name}}“ als eigene Metadaten in dieses Laufzeitsystem kopiert, in die Basis {{base}}. Die Kopie ist bearbeitbar und erhält kein Upgrade.", unmanagedStrippedLocks: "Das Paket hat diese Elemente gesperrt. Die Kopie ist bearbeitbar, daher wurden ihre Sperren nicht kopiert:", unmanagedIgnored: "Dieses Laufzeitsystem unterstützt keine nicht verwalteten Installationen, daher wurde „{{name}}“ verwaltet installiert (als Code registriert) statt kopiert. Aktualisieren Sie das Laufzeitsystem, deinstallieren Sie dann das Paket und installieren Sie es erneut als nicht verwaltete Kopie.", + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "{{name}} installieren?", + reinstallTitle: "{{name}} neu installieren?", + intro: "Die Installation fügt diesem Laufzeitsystem Folgendes hinzu:", + reading: "Es wird gelesen, was dieses Paket hinzufügt…", + unreadable: "Was dieses Paket hinzufügt, konnte vor der Installation nicht gelesen werden. Das Installationsergebnis nennt, was geladen wurde.", + objects: "Objekte", + permissionSets: "Berechtigungssätze", + sampleRecords: "Beispieldatensätze", + atMost: "bis zu {{count}}", + sampleNote: "Beispieldatensätze werden mit der App geladen. Sie können sie nach der Installation auf dieser Seite entfernen.", + suggestsEveryone: "Danach wird empfohlen, den Berechtigungssatz {{set}} allen angemeldeten Benutzern (Position Everyone) zu gewähren. Gewährt wird nichts, bevor Sie zustimmen.", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 51dbfe3b44a..f75dd6a3c0b 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5064,6 +5064,21 @@ const en = { unmanagedSuccess: 'Copied v{{version}} of "{{name}}" into this runtime as its own metadata, in the base {{base}}. The copy is editable and receives no upgrade.', unmanagedStrippedLocks: 'The package locked these items. The copy is editable, so their locks were not copied:', unmanagedIgnored: 'This runtime does not support unmanaged installs, so "{{name}}" was installed managed (registered as code) instead of copied. Upgrade the runtime, then uninstall the package and install it again as an unmanaged copy.', + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "Install {{name}}?", + reinstallTitle: "Reinstall {{name}}?", + intro: "Installing adds the following to this runtime:", + reading: "Reading what this package adds…", + unreadable: "What this package adds could not be read before install. The install result says what it loaded.", + objects: "Objects", + permissionSets: "Permission sets", + sampleRecords: "Sample records", + atMost: "up to {{count}}", + sampleNote: "Sample records load with the app. You can clear them from this page after install.", + suggestsEveryone: "Afterwards it suggests granting \"{{set}}\" to all signed-in users (the Everyone position). Nothing is granted until you accept.", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 690a59b0c86..42e56f52616 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4345,6 +4345,21 @@ const es = { unmanagedSuccess: "v{{version}} de \"{{name}}\" copiado en este runtime como sus propios metadatos, en la base {{base}}. La copia se puede editar y no recibe actualizaciones.", unmanagedStrippedLocks: "El paquete bloqueaba estos elementos. La copia se puede editar, así que sus bloqueos no se copiaron:", unmanagedIgnored: "Este runtime no admite instalaciones no administradas, así que \"{{name}}\" se instaló como administrado (registrado como código) en lugar de copiarse. Actualiza el runtime, luego desinstala el paquete y vuelve a instalarlo como copia no administrada.", + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "¿Instalar {{name}}?", + reinstallTitle: "¿Reinstalar {{name}}?", + intro: "La instalación añade lo siguiente a este runtime:", + reading: "Leyendo lo que añade este paquete…", + unreadable: "No se pudo leer antes de instalar lo que añade este paquete. El resultado de la instalación indica lo que se cargó.", + objects: "Objetos", + permissionSets: "Conjuntos de permisos", + sampleRecords: "Registros de ejemplo", + atMost: "hasta {{count}}", + sampleNote: "Los registros de ejemplo se cargan con la aplicación. Puedes borrarlos desde esta página después de instalar.", + suggestsEveryone: "Después sugerirá conceder «{{set}}» a todos los usuarios con sesión iniciada (la posición Everyone). No se concede nada hasta que lo aceptes.", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 0bdbb1536c6..26a918f9ecd 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4341,6 +4341,21 @@ const fr = { unmanagedSuccess: "v{{version}} de \"{{name}}\" copié dans ce runtime comme ses propres métadonnées, dans la base {{base}}. La copie est modifiable et ne reçoit aucune mise à niveau.", unmanagedStrippedLocks: "Le paquet verrouillait ces éléments. La copie est modifiable, leurs verrous n'ont donc pas été copiés :", unmanagedIgnored: "Ce runtime ne prend pas en charge les installations non gérées : \"{{name}}\" a donc été installé en mode géré (enregistré comme code) au lieu d'être copié. Mettez à niveau le runtime, puis désinstallez le paquet et réinstallez-le comme copie non gérée.", + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "Installer {{name}} ?", + reinstallTitle: "Réinstaller {{name}} ?", + intro: "L'installation ajoute ce qui suit à ce runtime :", + reading: "Lecture de ce que ce paquet ajoute…", + unreadable: "Ce que ce paquet ajoute n'a pas pu être lu avant l'installation. Le résultat de l'installation indique ce qui a été chargé.", + objects: "Objets", + permissionSets: "Ensembles d'autorisations", + sampleRecords: "Enregistrements d'exemple", + atMost: "jusqu'à {{count}}", + sampleNote: "Les enregistrements d'exemple sont chargés avec l'application. Vous pouvez les supprimer depuis cette page après l'installation.", + suggestsEveryone: "Ensuite, il suggérera d'accorder « {{set}} » à tous les utilisateurs connectés (position Everyone). Rien n'est accordé tant que vous n'acceptez pas.", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 1ec4cff7491..bd5e15a9899 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4258,6 +4258,21 @@ const ja = { unmanagedSuccess: "「{{name}}」の v{{version}} を、このランタイム自身のメタデータとしてベース {{base}} にコピーしました。コピーは編集でき、アップグレードは受け取りません。", unmanagedStrippedLocks: "パッケージはこれらの項目をロックしていました。コピーは編集可能なため、ロックはコピーされていません:", unmanagedIgnored: "このランタイムはアンマネージドインストールに対応していないため、「{{name}}」はコピーではなくマネージド(コードとして登録)でインストールされました。ランタイムをアップグレードしてから、パッケージをアンインストールし、アンマネージドコピーとして再度インストールしてください。", + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "{{name}} をインストールしますか?", + reinstallTitle: "{{name}} を再インストールしますか?", + intro: "インストールすると、このランタイムに次のものが追加されます。", + reading: "このパッケージが追加する内容を読み込んでいます…", + unreadable: "このパッケージが追加する内容をインストール前に読み取れませんでした。インストール結果に、読み込まれた内容が表示されます。", + objects: "オブジェクト", + permissionSets: "権限セット", + sampleRecords: "サンプルレコード", + atMost: "最大 {{count}}", + sampleNote: "サンプルレコードはアプリと一緒に読み込まれます。インストール後、このページから削除できます。", + suggestsEveryone: "インストール後、サインイン済みの全ユーザー(Everyone ポジション)に「{{set}}」を付与することが提案されます。承認するまで何も付与されません。", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index fbf6488dad1..6059ff24ea7 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4255,6 +4255,21 @@ const ko = { unmanagedSuccess: "\"{{name}}\" v{{version}}을(를) 이 런타임의 자체 메타데이터로 베이스 {{base}}에 복사했습니다. 사본은 편집할 수 있으며 업그레이드를 받지 않습니다.", unmanagedStrippedLocks: "패키지가 이 항목들을 잠갔습니다. 사본은 편집할 수 있으므로 잠금은 복사되지 않았습니다:", unmanagedIgnored: "이 런타임은 비관리형 설치를 지원하지 않으므로 \"{{name}}\"이(가) 복사되지 않고 관리형(코드로 등록)으로 설치되었습니다. 런타임을 업그레이드한 다음 패키지를 제거하고 비관리형 사본으로 다시 설치하세요.", + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "{{name}}을(를) 설치하시겠습니까?", + reinstallTitle: "{{name}}을(를) 다시 설치하시겠습니까?", + intro: "설치하면 이 런타임에 다음이 추가됩니다.", + reading: "이 패키지가 추가하는 내용을 읽는 중…", + unreadable: "설치 전에 이 패키지가 추가하는 내용을 읽을 수 없습니다. 설치 결과에 로드된 내용이 표시됩니다.", + objects: "객체", + permissionSets: "권한 세트", + sampleRecords: "샘플 레코드", + atMost: "최대 {{count}}", + sampleNote: "샘플 레코드는 앱과 함께 로드됩니다. 설치 후 이 페이지에서 지울 수 있습니다.", + suggestsEveryone: "설치 후 로그인한 모든 사용자(Everyone 위치)에게 \"{{set}}\"을(를) 부여할 것을 제안합니다. 수락하기 전에는 아무것도 부여되지 않습니다.", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index b403077080b..c2da66e10d9 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4340,6 +4340,21 @@ const pt = { unmanagedSuccess: "v{{version}} de \"{{name}}\" copiado para este runtime como seus próprios metadados, na base {{base}}. A cópia pode ser editada e não recebe atualizações.", unmanagedStrippedLocks: "O pacote bloqueava estes itens. A cópia pode ser editada, então seus bloqueios não foram copiados:", unmanagedIgnored: "Este runtime não oferece suporte a instalações não gerenciadas, então \"{{name}}\" foi instalado como gerenciado (registrado como código) em vez de copiado. Atualize o runtime, depois desinstale o pacote e instale-o novamente como cópia não gerenciada.", + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "Instalar {{name}}?", + reinstallTitle: "Reinstalar {{name}}?", + intro: "A instalação adiciona o seguinte a este runtime:", + reading: "Lendo o que este pacote adiciona…", + unreadable: "Não foi possível ler, antes da instalação, o que este pacote adiciona. O resultado da instalação informa o que foi carregado.", + objects: "Objetos", + permissionSets: "Conjuntos de permissões", + sampleRecords: "Registros de exemplo", + atMost: "até {{count}}", + sampleNote: "Os registros de exemplo são carregados com o app. Você pode removê-los nesta página após a instalação.", + suggestsEveryone: "Depois, ele sugere conceder \"{{set}}\" a todos os usuários autenticados (a posição Everyone). Nada é concedido até que você aceite.", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 5c21ffd35ff..1f11d74554c 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4443,6 +4443,21 @@ const ru = { unmanagedSuccess: "v{{version}} пакета \"{{name}}\" скопирована в эту среду выполнения как её собственные метаданные, в базу {{base}}. Копию можно редактировать, обновлений она не получает.", unmanagedStrippedLocks: "Пакет блокировал эти элементы. Копию можно редактировать, поэтому их блокировки не скопированы:", unmanagedIgnored: "Эта среда выполнения не поддерживает неуправляемую установку, поэтому \"{{name}}\" установлен как управляемый (зарегистрирован как код), а не скопирован. Обновите среду выполнения, затем удалите пакет и установите его снова как неуправляемую копию.", + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "Установить {{name}}?", + reinstallTitle: "Переустановить {{name}}?", + intro: "Установка добавит в эту среду выполнения:", + reading: "Читаю, что добавляет этот пакет…", + unreadable: "Не удалось до установки прочитать, что добавляет этот пакет. Результат установки покажет, что было загружено.", + objects: "Объекты", + permissionSets: "Наборы разрешений", + sampleRecords: "Демонстрационные записи", + atMost: "до {{count}}", + sampleNote: "Демонстрационные записи загружаются вместе с приложением. После установки их можно удалить на этой странице.", + suggestsEveryone: "После установки будет предложено предоставить «{{set}}» всем вошедшим пользователям (позиция Everyone). Ничего не предоставляется, пока вы не примете предложение.", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 75bc7bdacc9..d3231eecc4f 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4444,6 +4444,21 @@ const zh = { unmanagedSuccess: '已将 "{{name}}" 的 v{{version}} 复制到本运行时,成为它自己的元数据,位于基础包 {{base}} 中。副本可编辑,且不再接收升级。', unmanagedStrippedLocks: '软件包锁定了以下条目。副本可编辑,因此这些锁未被复制:', unmanagedIgnored: '本运行时不支持非托管安装,因此 "{{name}}" 以托管方式安装(以代码形式注册),而不是被复制。请先升级运行时,然后卸载该软件包,再以非托管副本重新安装。', + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "安装 {{name}}?", + reinstallTitle: "重新安装 {{name}}?", + intro: "安装会向此运行时加入以下内容:", + reading: "正在读取此软件包将加入的内容…", + unreadable: "安装前无法读取此软件包将加入的内容。安装结果会说明加载了什么。", + objects: "对象", + permissionSets: "权限集", + sampleRecords: "示例记录", + atMost: "最多 {{count}}", + sampleNote: "示例记录随应用一起加载,安装后可在本页清除。", + suggestsEveryone: "安装后会建议将「{{set}}」授予所有已登录用户(Everyone 岗位)。在你接受之前不会授予任何权限。", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage).