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
7 changes: 7 additions & 0 deletions .changeset/12610-i18n-kernel-note-plain-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@object-ui/i18n': patch
---

**`marketplace.installedAdditiveNote` is plain text in all ten packs** (objectui#12610). Each value dropped its leading `<strong>Note:</strong>` markup (in that pack's language); the sentence after it is unchanged. The console draws the key as the tooltip of Installed Apps' Uninstall, which renders text, not HTML. The key's name does not change.

**Clause-②: yes.** The value is part of the published `en` pack and its nine siblings. A consumer that rendered this key as HTML now renders the same sentence without the bold "Note:" prefix; one that rendered it as text no longer shows literal tags.
5 changes: 5 additions & 0 deletions .changeset/12610-installed-apps-kernel-note-tooltip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

**Installed Apps' kernel note is the tooltip of the Uninstall it qualifies** (objectui#12610). The "kernel API is additive only" note was a paragraph at the foot of the list, shown under the empty state too. It now opens as a tooltip on hover or focus of a loaded local row's Uninstall, and is that button's accessible description. A not-loaded row's Uninstall carries no note, since that package left nothing in the running kernel (objectui#11645), and a cloud-bound list, which offers no Uninstall, carries none either. The note renders as text; the widget no longer passes it through `dangerouslySetInnerHTML`.
5 changes: 5 additions & 0 deletions .changeset/12610-settings-action-row-one-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/console': patch
---

**A settings action row draws its label once, on the button** (objectui#12610). The settings renderer's `action_button` row printed the manifest's `label` as the row label and again on the button, so Mail Delivery read "Send test email" twice and AI & Embedder repeated "Test connection", "Reset to environment defaults" and "Test embedder". The button now carries the label alone. The row's left side holds the specifier's `description` when the manifest declares one, as one line of muted text, and nothing otherwise. Settings keys, values and save behaviour do not change.
16 changes: 9 additions & 7 deletions apps/console/src/pages/settings/SettingsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,14 +313,16 @@ export function SettingsField(props: SettingsFieldProps) {
const actionLabel = labels
? labels.actionLabel(actionId, literalLabel)
: literalLabel;
// One label per action row (objectui#12610): the button carries it. The
// row's left side holds the manifest's one-line `description` when it
// declares one, and nothing otherwise — the label is never drawn twice.
return (
<div className="flex items-center justify-between py-3">
<div>
<div className="text-sm font-medium">{actionLabel}</div>
{spec.description ? (
<p className="text-xs text-muted-foreground mt-1">{spec.description}</p>
) : null}
</div>
<div
className={`flex items-center gap-4 py-3 ${spec.description ? 'justify-between' : 'justify-end'}`}
>
{spec.description ? (
<p className="text-sm text-muted-foreground">{spec.description}</p>
) : null}
<Button size="sm" variant="secondary" onClick={onAction} disabled={saving}>
{Icon ? (
// eslint-disable-next-line react-hooks/static-components -- getLazyIcon returns a module-cached stable component per name, not one created during render
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* An action row has ONE label, and the button carries it (objectui#12610).
*
* The settings hub walk measured "Send test email" as a row label AND as the
* button beside it on Mail Delivery, and the same pair on every
* `action_button` of AI & Embedder, Knowledge and File Storage. The manifests
* declare one `label` per `action_button`; drawing it twice was this renderer,
* whose `action_button` branch printed the label on the left and again in the
* button. That branch is the only place any settings page draws an action row,
* so the pin is over the page, for every action row the renderer draws:
*
* - no string a button carries is also drawn outside it, on any row;
* - the row's left side holds the manifest's `description` when it declares
* one, and nothing otherwise — the renderer never invents help text.
*
* The page cases render the REAL `SettingsView` over a stubbed `fetch`, with
* the `action_button` specifiers the server's settings manifests declare today
* (id, label, icon), so a label translated through the `actions.ID.label`
* convention is judged the same way.
*/

import type { ReactElement } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { I18nProvider } from '@object-ui/i18n';

vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }));

import { SettingsView } from '../SettingsView';
import { SettingsField } from '../SettingsField';
import type { Specifier } from '../types';

const fetchMock = vi.fn();

beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

function ok(body: unknown): Response {
return { ok: true, status: 200, statusText: 'OK', json: async () => body } as unknown as Response;
}

function withI18n(ui: ReactElement) {
return <I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>{ui}</I18nProvider>;
}

function renderView(namespace: string, specifiers: Specifier[]) {
fetchMock.mockResolvedValue(
ok({
success: true,
data: {
manifest: { namespace, version: 1, label: `Page ${namespace}`, specifiers },
values: {},
},
}),
);
return render(
withI18n(
<MemoryRouter initialEntries={[`/settings/${namespace}`]}>
<Routes>
<Route path="/settings/:namespace" element={<SettingsView />} />
</Routes>
</MemoryRouter>,
),
);
}

/**
* Every element OUTSIDE `button` whose own text is `label`. Own text = the
* element's direct text nodes, so a wrapper is not counted for its child's
* words and the count is of places the string is drawn.
*/
function drawnOutside(label: string, button: HTMLElement): Element[] {
return Array.from(document.body.querySelectorAll('*')).filter((el) => {
if (button.contains(el)) return false;
const own = Array.from(el.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent ?? '')
.join('')
.trim();
return own === label;
});
}

/**
* The `action_button` specifiers objectstack's settings manifests declare
* today, by page: Mail Delivery, SMS Delivery, AI & Embedder, Knowledge and
* File Storage. None declares a `description`.
*/
const PAGES: Array<[string, Specifier[]]> = [
['mail', [{ type: 'action_button', id: 'test', label: 'Send test email', icon: 'Send' }]],
['sms', [{ type: 'action_button', id: 'test', label: 'Send test SMS', icon: 'Send' }]],
[
'ai',
[
{ type: 'action_button', id: 'test', label: 'Test connection', icon: 'Plug' },
{ type: 'action_button', id: 'reset', label: 'Reset to environment defaults', icon: 'RotateCcw' },
{ type: 'action_button', id: 'test_embedder', label: 'Test embedder', icon: 'Plug' },
],
],
['knowledge', [{ type: 'action_button', id: 'test', label: 'Test connection', icon: 'Plug' }]],
['storage', [{ type: 'action_button', id: 'test', label: 'Test connection', icon: 'Plug' }]],
];

describe('a settings action row draws its label once, on the button (objectui#12610)', () => {
it.each(PAGES)('the %s page: every action label is on its button and nowhere else', async (namespace, specs) => {
renderView(namespace, specs);
expect(await screen.findByText(`Page ${namespace}`)).toBeInTheDocument();

for (const spec of specs) {
const label = spec.label as string;
const button = screen.getByRole('button', { name: label });
expect(drawnOutside(label, button)).toEqual([]);
// No description declared ⇒ the row holds the button and nothing else.
expect(button.parentElement?.children).toHaveLength(1);
}
// Control: the walk above judged as many rows as the page declares.
const buttons = specs.map((s) => screen.getByRole('button', { name: s.label as string }));
expect(buttons).toHaveLength(specs.length);
});

it('a label translated through `actions.ID.label` is drawn once too, the translation on the button', async () => {
// A bundle that translates the action through the convention the label
// hook resolves (`NS.settings.NAMESPACE.actions.ID.label`).
fetchMock.mockResolvedValue(
ok({
success: true,
data: {
manifest: {
namespace: 'mail',
version: 1,
label: 'Page mail',
specifiers: [{ type: 'action_button', id: 'test', label: 'Send test email', icon: 'Send' }],
},
values: {},
},
}),
);
render(
<I18nProvider
config={{
defaultLanguage: 'en',
detectBrowserLanguage: false,
resources: {
en: { probe12610: { settings: { mail: { actions: { test: { label: 'Mail a test' } } } } } },
},
}}
>
<MemoryRouter initialEntries={['/settings/mail']}>
<Routes>
<Route path="/settings/:namespace" element={<SettingsView />} />
</Routes>
</MemoryRouter>
</I18nProvider>,
);
const button = await screen.findByRole('button', { name: 'Mail a test' });
expect(drawnOutside('Mail a test', button)).toEqual([]);
expect(screen.queryByText('Send test email')).toBeNull();
});
});

describe('the action row\'s left side is the manifest\'s description or nothing (objectui#12610)', () => {
it('a declared description is the left side, once, and the label stays on the button only', () => {
const spec: Specifier = {
type: 'action_button',
id: 'test',
label: 'Send test email',
description: 'Sends a message to the from address',
};
render(<SettingsField spec={spec} value={undefined} onChange={() => {}} />);
const button = screen.getByRole('button', { name: 'Send test email' });
expect(drawnOutside('Send test email', button)).toEqual([]);
const row = button.parentElement as HTMLElement;
expect(row.children).toHaveLength(2);
expect(within(row).getByText('Sends a message to the from address')).toBeInTheDocument();
expect(button).not.toHaveTextContent('Sends a message');
});

it('no description ⇒ nothing beside the button', () => {
render(
<SettingsField
spec={{ type: 'action_button', id: 'test', label: 'Test embedder' }}
value={undefined}
onChange={() => {}}
/>,
);
const button = screen.getByRole('button', { name: 'Test embedder' });
expect(button.parentElement?.children).toHaveLength(1);
expect(button.parentElement?.textContent).toBe('Test embedder');
});
});
61 changes: 45 additions & 16 deletions packages/app-shell/src/console/marketplace/InstalledListWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* entries.
*/

import { useEffect, useState } from 'react';
import { useEffect, useId, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Card,
Expand All @@ -23,6 +23,10 @@ import {
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';
Expand All @@ -47,6 +51,9 @@ export function InstalledList() {
// 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<LocalInstallEntry[]>([]);
// 'cloud' = list comes from the control plane (CLI/marketplace/REST installs,
Expand Down Expand Up @@ -113,6 +120,37 @@ export function InstalledList() {
}
};

// 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 = (
<Button
variant="outline"
size="sm"
onClick={() => void doUninstall(entry)}
disabled={working === entry.manifestId}
aria-describedby={entry.notLoaded ? undefined : kernelNoteId}
>
<Trash2 className="h-4 w-4 mr-1.5" aria-hidden="true" />
{working === entry.manifestId ? t('marketplace.action.uninstalling') : t('marketplace.action.uninstall')}
</Button>
);
if (entry.notLoaded) return button;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{t('marketplace.installedAdditiveNote')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

if (!isAdmin) return <MarketplaceAccessDenied />;

return (
Expand Down Expand Up @@ -184,17 +222,7 @@ export function InstalledList() {
<ExternalLink className="h-4 w-4 mr-1.5" aria-hidden="true" />
{t('marketplace.action.details')}
</Button>
{source === 'local' && (
<Button
variant="outline"
size="sm"
onClick={() => void doUninstall(entry)}
disabled={working === entry.manifestId}
>
<Trash2 className="h-4 w-4 mr-1.5" aria-hidden="true" />
{working === entry.manifestId ? t('marketplace.action.uninstalling') : t('marketplace.action.uninstall')}
</Button>
)}
{source === 'local' && uninstallAction(entry)}
</div>
</CardHeader>
{source === 'local' && (
Expand All @@ -212,10 +240,11 @@ export function InstalledList() {
</div>
)}

<p
className="text-xs text-muted-foreground border-t pt-4"
dangerouslySetInnerHTML={{ __html: t('marketplace.installedAdditiveNote') }}
/>
{/* 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) && (
<span id={kernelNoteId} className="sr-only">{t('marketplace.installedAdditiveNote')}</span>
)}
</div>
);
}
Expand Down
Loading
Loading