Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/30325-install-confirm-what-it-adds.md
Original file line number Diff line number Diff line change
@@ -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 languages, in the lazy `marketplace` namespace (`@object-ui/i18n/namespaces/marketplace`) that loads with the marketplace pages, so the eager language packs do not grow.
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ import { localizePackage } from './usePackageL10n.js';
import { isNewerVersion } from './versionPrecedence.js';
import {
getMarketplacePackage,
getDeclaredInstallModes,
getServedPackageBody,
declaredInstallModesOf,
installPackage,
installLocal,
uninstallLocal,
Expand All @@ -93,6 +94,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,
noteServedSampleCount,
Expand Down Expand Up @@ -325,6 +327,12 @@ function MarketplacePackagePageBody() {
// 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.
Expand Down Expand Up @@ -375,10 +383,15 @@ function MarketplacePackagePageBody() {
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; };
Expand Down Expand Up @@ -641,13 +654,24 @@ function MarketplacePackagePageBody() {
}
};

/**
* 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.
Expand Down Expand Up @@ -1359,6 +1383,7 @@ function MarketplacePackagePageBody() {
? 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
Expand Down Expand Up @@ -1641,6 +1666,63 @@ function MarketplacePackagePageBody() {
</div>
</div>

{/* 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. */}
<AlertDialog open={confirmLocalOpen} onOpenChange={setConfirmLocalOpen}>
<AlertDialogContent data-testid="install-preview-dialog">
<AlertDialogHeader>
<AlertDialogTitle>
{localInstall
? t('marketplace.install.preview.reinstallTitle', { name: loc.displayName })
: t('marketplace.install.preview.title', { name: loc.displayName })}
</AlertDialogTitle>
<AlertDialogDescription>
{!localPreviewRead
? t('marketplace.install.preview.reading')
: localPreviewRead.preview
? t('marketplace.install.preview.intro')
: t('marketplace.install.preview.unreadable')}
</AlertDialogDescription>
</AlertDialogHeader>
{localPreviewRead?.preview && (
<div className="space-y-3 text-sm" data-testid="install-preview">
<dl className="grid grid-cols-[auto_auto] justify-start gap-x-6 gap-y-1">
<dt className="text-muted-foreground">{t('marketplace.install.preview.objects')}</dt>
<dd className="font-medium tabular-nums" data-testid="install-preview-objects">{localPreviewRead.preview.objects}</dd>
<dt className="text-muted-foreground">{t('marketplace.install.preview.permissionSets')}</dt>
<dd className="font-medium tabular-nums" data-testid="install-preview-permission-sets">{localPreviewRead.preview.permissionSets}</dd>
<dt className="text-muted-foreground">{t('marketplace.install.preview.sampleRecords')}</dt>
<dd className="font-medium tabular-nums" data-testid="install-preview-sample-records">
{localPreviewRead.preview.sampleRecordsAtMost
? t('marketplace.install.preview.atMost', { count: localPreviewRead.preview.sampleRecords })
: localPreviewRead.preview.sampleRecords}
</dd>
</dl>
{localPreviewRead.preview.sampleRecords > 0 && (
<p className="text-muted-foreground">{t('marketplace.install.preview.sampleNote')}</p>
)}
{localPreviewRead.preview.suggestedGrants.length > 0 && (
<ul className="space-y-1" data-testid="install-preview-suggestions">
{localPreviewRead.preview.suggestedGrants.map((set) => (
<li key={set}>{t('marketplace.install.preview.suggestsEveryone', { set })}</li>
))}
</ul>
)}
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
data-testid="install-preview-confirm"
onClick={() => { void runInstallLocal(); }}
>
{localInstall ? t('marketplace.action.reinstall') : t('marketplace.action.install')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

<Dialog open={installOpen} onOpenChange={(o) => { setInstallOpen(o); if (!o) { setInstallResult(null); setAcknowledged(false); } }}>
<DialogContent
onCloseAutoFocus={(e) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => {
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: () => <div data-testid="suggested-bindings" />,
}));
vi.mock('../ConnectorCredentialsStep', () => ({
ConnectorCredentialsStep: () => <div data-testid="credential-step" />,
}));

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(<MarketplacePackagePage />);
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));
});
});
Loading
Loading