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
12 changes: 10 additions & 2 deletions web-ui/app/_lib/agents.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApiError } from './api';
import type { LocalizedMarkdown } from './storeTypes';

/**
* Typed client for the operator multi-orchestrator REST surface
Expand Down Expand Up @@ -821,9 +822,16 @@ export type SetupFieldType =

export interface PluginSetupFieldDto {
key: string;
label: string;
/** #602 (OM-17) — the manifest loader normalises `label` into a
* `{ <locale>: text }` map (`?? { en: key }`), so this is NOT a plain
* string on any current middleware. Rendering it directly threw React #31
* ("Objects are not valid as a React child") and took the whole orchestrator
* page down via the route error boundary. Resolve with `pickLocalized`.
* The bare-string arm covers payloads from a pre-#602 middleware. */
label: LocalizedMarkdown | string;
type: SetupFieldType;
help?: string;
/** #602 (OM-17) — localized help map; same contract as `label`. */
help?: LocalizedMarkdown | string;
default?: string | string[];
enum?: Array<{ value: string; label: string }>;
}
Expand Down
9 changes: 8 additions & 1 deletion web-ui/app/_lib/localized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ import type { LocalizedMarkdown } from './storeTypes';
* locale. Falls back to English, then German, then any remaining locale, so a
* guide that ships only one language still renders. Returns undefined when the
* map is empty or absent.
*
* A bare string is returned as-is. The manifest loader normalises setup text
* into a map, but a payload from a pre-#602 middleware still ships the plain
* string — without this branch `Object.values('abc')` would resolve to `'a'`.
*/
export function pickLocalized(
map: LocalizedMarkdown | undefined,
map: LocalizedMarkdown | string | undefined,
locale: string,
): string | undefined {
if (typeof map === 'string') {
return map.trim().length > 0 ? map : undefined;
}
if (!map) return undefined;
const direct = map[locale];
if (direct && direct.trim().length > 0) return direct;
Expand Down
17 changes: 13 additions & 4 deletions web-ui/app/operator/agents/_components/PluginsDnd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import {
import { CSS } from '@dnd-kit/utilities';
import { GripVertical } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';

import { Button } from '@/app/_components/ui/Button';
import { pickLocalized } from '@/app/_lib/localized';
import type {
OperatorAgentDto,
PluginCatalogEntryDto,
Expand Down Expand Up @@ -780,6 +781,14 @@ function PluginConfigField(props: {
onChange: (value: string | boolean | number | string[]) => void;
}): React.ReactElement {
const { field, value, disabled, onChange } = props;
const locale = useLocale();
// #602 (OM-17) — `label` / `help` arrive as `{ <locale>: text }` maps from
// the manifest loader. Rendering the map object straight into JSX threw
// React #31 and replaced the whole orchestrator page with the route error
// boundary the moment "Config" was clicked. `key` is the loader's own
// fallback for a label-less field, so it is the right last resort here too.
const label = pickLocalized(field.label, locale) ?? field.key;
const help = pickLocalized(field.help, locale);
const isSecret = field.type === 'secret' || field.type === 'password';
const isHostList = field.type === 'host_list';
const isEnum = field.type === 'enum' && (field.enum?.length ?? 0) > 0;
Expand All @@ -789,9 +798,9 @@ function PluginConfigField(props: {
return (
<label className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wide text-[color:var(--fg-muted)]">
{field.label}
{field.help && (
<span className="ml-1 text-[color:var(--fg-subtle)]">— {field.help}</span>
{label}
{help && (
<span className="ml-1 text-[color:var(--fg-subtle)]">— {help}</span>
)}
</span>
{isSecret ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';

import type {
OperatorAgentDto,
PluginCatalogEntryDto,
} from '../../../../_lib/agents';
import { renderWithIntl } from '../../../../_lib/test-utils';
import { PluginsDnd } from '../PluginsDnd';

/**
* Regression — clicking "Config" on an attached plugin blanked the whole
* orchestrator page with the route error boundary ("Etwas ist schiefgelaufen").
*
* Root cause: since #602 (OM-17) the manifest loader normalises every setup
* field's `label` / `help` into a `{ <locale>: text }` map (`?? { en: key }`),
* so NO field on a current middleware ships a bare string — 192 of 192 fields
* in the live catalog were maps. This component still rendered `field.label`
* straight into JSX, which is React #31 ("Objects are not valid as a React
* child"). It threw on the first render after the drawer opened, i.e. every
* plugin with setup fields was unconfigurable from this page.
*
* Guarded here: the drawer opens, the map resolves to the active locale, and
* a pre-#602 bare string still renders as itself.
*
* dnd-kit DRAG stays out of jsdom scope (see vitest.config.ts) — this drives
* the button, not the pointer sensor.
*/

function agent(): OperatorAgentDto {
return {
id: 'a1',
slug: 'marketing',
name: 'marketing',
privacyProfile: 'default',
enabled: true,
plugins: [{ id: '@omadia/confluence', config: {}, enabled: true }],
bindings: [],
} as unknown as OperatorAgentDto;
}

function catalog(
fields: PluginCatalogEntryDto['setup_fields'],
): PluginCatalogEntryDto[] {
return [
{
id: '@omadia/confluence',
name: 'Confluence Connector',
kind: 'integration',
version: '0.3.3',
multi_instance: true,
privacy_class: 'default',
memory_reads: [],
memory_writes: [],
network_outbound: [],
setup_fields: fields,
depends_on: [],
},
];
}

function renderDnd(
fields: PluginCatalogEntryDto['setup_fields'],
locale: 'de' | 'en' = 'en',
): void {
renderWithIntl(
<PluginsDnd
agent={agent()}
catalog={catalog(fields)}
isFallback={false}
disabled={false}
onReplace={vi.fn()}
/>,
{ locale },
);
}

describe('PluginsDnd config drawer — localized setup fields', () => {
it('opens the drawer and renders a localized label map instead of crashing', async () => {
const user = userEvent.setup();
renderDnd([
{
key: 'confluence_base_url',
label: { en: 'Confluence Base URL', de: 'Confluence Basis-URL' },
type: 'string',
help: { en: 'No trailing slash needed.' },
},
]);

await user.click(screen.getByRole('button', { name: 'Config' }));

expect(screen.getByText(/Confluence Base URL/)).toBeInTheDocument();
expect(screen.getByText(/No trailing slash needed\./)).toBeInTheDocument();
});

it('prefers the active locale', async () => {
const user = userEvent.setup();
renderDnd(
[
{
key: 'confluence_base_url',
label: { en: 'Confluence Base URL', de: 'Confluence Basis-URL' },
type: 'string',
},
],
'de',
);

await user.click(screen.getByRole('button', { name: 'Config' }));

expect(screen.getByText(/Confluence Basis-URL/)).toBeInTheDocument();
});

it('still renders a bare string from a pre-#602 middleware', async () => {
const user = userEvent.setup();
renderDnd([
{ key: 'legacy_key', label: 'Legacy Label', type: 'string' },
]);

await user.click(screen.getByRole('button', { name: 'Config' }));

expect(screen.getByText(/Legacy Label/)).toBeInTheDocument();
});

it('falls back to the field key when the map is empty', async () => {
const user = userEvent.setup();
renderDnd([{ key: 'orphan_key', label: {}, type: 'string' }]);

await user.click(screen.getByRole('button', { name: 'Config' }));

expect(screen.getByText(/orphan_key/)).toBeInTheDocument();
});
});
Loading