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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- **Studio: Code tab now shows CodeExporter** — The Code tab in Studio metadata detail pages
now correctly renders the `CodeExporter` component (TypeScript/JSON export with copy-to-clipboard)
instead of always showing the JSON Inspector preview. The default plugin now registers two separate
viewers: `json-inspector` for preview mode and `code-exporter` for code mode.
- **CI Test Failures** — Resolved test failures across multiple packages:
- `@objectstack/service-ai`: Fixed SDK fallback test by mocking `@ai-sdk/openai` dynamic import
(SDK now available as transitive workspace dependency)
Expand Down
104 changes: 95 additions & 9 deletions apps/studio/src/plugins/built-in/default-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,130 @@
/**
* Built-in Plugin: Default Metadata Inspector
*
* Provides a JSON tree viewer as the fallback for any metadata type
* that doesn't have a specialized plugin. This is the "catch-all" viewer.
* Provides two fallback viewers for any metadata type:
* - **json-inspector** (preview mode): JSON tree viewer via MetadataInspector
* - **code-exporter** (code mode): Exportable TypeScript/JSON via CodeExporter
*
* Priority is set to -1 so any type-specific plugin will take precedence.
*/

import { useState, useEffect } from 'react';
import { defineStudioPlugin } from '@objectstack/spec/studio';
import { useClient } from '@objectstack/client-react';
import { MetadataInspector } from '@/components/MetadataInspector';
import { CodeExporter } from '@/components/CodeExporter';
import type { CodeExporterProps } from '@/components/CodeExporter';
import type { StudioPlugin, MetadataViewerProps } from '../types';

// ─── Viewer Component (adapts MetadataInspector to plugin interface)
// ─── Helpers ────────────────────────────────────────────────────────

function DefaultViewerComponent({ metadataType, metadataName, packageId }: MetadataViewerProps) {
/** Map Studio metadataType (often plural) to CodeExporter's ExportType (singular). */
const METADATA_TO_EXPORT_TYPE: Record<string, CodeExporterProps['type']> = {
object: 'object',
objects: 'object',
view: 'view',
views: 'view',
flow: 'flow',
flows: 'flow',
agent: 'agent',
agents: 'agent',
app: 'app',
apps: 'app',
};

// ─── Preview Viewer (JSON Inspector) ─────────────────────────────────

function PreviewViewerComponent({ metadataType, metadataName, packageId }: MetadataViewerProps) {
return <MetadataInspector metaType={metadataType} metaName={metadataName} packageId={packageId} />;
}

// ─── Code Viewer (Code Exporter) ─────────────────────────────────────

function CodeViewerComponent({ metadataType, metadataName, data, packageId }: MetadataViewerProps) {
const client = useClient();
const [definition, setDefinition] = useState<Record<string, unknown> | null>(data ?? null);
const [loading, setLoading] = useState(!data);

useEffect(() => {
// If data was passed directly, use it
if (data) {
setDefinition(data as Record<string, unknown>);
setLoading(false);
return;
}

let mounted = true;
setLoading(true);

async function load() {
try {
const result: any = await client.meta.getItem(metadataType, metadataName, packageId ? { packageId } : undefined);
if (mounted) {
setDefinition(result?.item || result);
}
} catch (err) {
console.error(`[CodeViewer] Failed to load ${metadataType}/${metadataName}:`, err);
} finally {
if (mounted) setLoading(false);
}
}
load();
return () => { mounted = false; };
}, [client, metadataType, metadataName, data, packageId]);

if (loading) {
Comment on lines +47 to +77
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeViewerComponent keeps definition state across metadata navigation. When metadataType/metadataName changes, the component can render once with the previous definition but the new metadataName (until the effect flips loading), which can briefly show incorrect exported code.

To avoid this, track which {metadataType, metadataName, packageId} the current definition belongs to (e.g., a loadedKey state) and render the loading/empty state whenever the props key doesn’t match, or clear definition synchronously when props change (so you never render with stale data).

Suggested change
const [definition, setDefinition] = useState<Record<string, unknown> | null>(data ?? null);
const [loading, setLoading] = useState(!data);
useEffect(() => {
// If data was passed directly, use it
if (data) {
setDefinition(data as Record<string, unknown>);
setLoading(false);
return;
}
let mounted = true;
setLoading(true);
async function load() {
try {
const result: any = await client.meta.getItem(metadataType, metadataName, packageId ? { packageId } : undefined);
if (mounted) {
setDefinition(result?.item || result);
}
} catch (err) {
console.error(`[CodeViewer] Failed to load ${metadataType}/${metadataName}:`, err);
} finally {
if (mounted) setLoading(false);
}
}
load();
return () => { mounted = false; };
}, [client, metadataType, metadataName, data, packageId]);
if (loading) {
const currentKey = `${metadataType}::${metadataName}::${packageId ?? ''}`;
const [definition, setDefinition] = useState<Record<string, unknown> | null>(data ?? null);
const [loadedKey, setLoadedKey] = useState<string | null>(data ? currentKey : null);
const [loading, setLoading] = useState(!data);
useEffect(() => {
// If data was passed directly, bind it to the current metadata identity.
if (data) {
setDefinition(data as Record<string, unknown>);
setLoadedKey(currentKey);
setLoading(false);
return;
}
let mounted = true;
setLoading(true);
setDefinition(null);
setLoadedKey(null);
async function load() {
try {
const result: any = await client.meta.getItem(metadataType, metadataName, packageId ? { packageId } : undefined);
if (mounted) {
setDefinition((result?.item || result) ?? null);
setLoadedKey(currentKey);
}
} catch (err) {
console.error(`[CodeViewer] Failed to load ${metadataType}/${metadataName}:`, err);
if (mounted) {
setDefinition(null);
setLoadedKey(currentKey);
}
} finally {
if (mounted) setLoading(false);
}
}
load();
return () => { mounted = false; };
}, [client, metadataType, metadataName, data, packageId, currentKey]);
if (loading || loadedKey !== currentKey) {

Copilot uses AI. Check for mistakes.
return (
<div className="p-4 text-sm text-muted-foreground">Loading definition…</div>
);
}

if (!definition) {
return (
<div className="p-4 text-sm text-muted-foreground">
Definition not found: <code className="font-mono">{metadataName}</code>
</div>
);
}

const exportType = METADATA_TO_EXPORT_TYPE[metadataType] ?? 'object';

return (
<div className="p-4">
<CodeExporter type={exportType} definition={definition} name={metadataName} />
</div>
);
}

// ─── Plugin Definition ───────────────────────────────────────────────

export const defaultInspectorPlugin: StudioPlugin = {
manifest: defineStudioPlugin({
id: 'objectstack.default-inspector',
name: 'Default Metadata Inspector',
version: '1.0.0',
description: 'JSON tree viewer for any metadata type. Fallback when no specialized viewer is available.',
description: 'JSON tree viewer and code exporter for any metadata type. Fallback when no specialized viewer is available.',
contributes: {
metadataViewers: [
{
id: 'json-inspector',
metadataTypes: ['*'], // Wildcard: matches all types
metadataTypes: ['*'],
label: 'JSON Inspector',
priority: -1, // Lowest priority — any plugin overrides this
modes: ['preview', 'code'],
priority: -1,
modes: ['preview'],
},
{
id: 'code-exporter',
metadataTypes: ['*'],
label: 'Code Export',
priority: -1,
modes: ['code'],
},
Comment on lines 109 to 123
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code-exporter is registered for metadataTypes: ['*'], but CodeExporter only supports a limited set of export types (object|view|flow|agent|app). For other Studio metadata types (e.g. actions, pages, dashboards, workflows, ragPipelines, roles, etc.), exportType falls back to 'object', which will generate misleading/incorrect TypeScript (wrong top-level stack key).

Consider either (a) restricting the code-exporter contribution to only the metadata types you can correctly map, or (b) detecting unsupported types in CodeViewerComponent and showing a clear “export not supported for this type” message / JSON-only export instead of defaulting to 'object'.

Copilot uses AI. Check for mistakes.
],
},
}),

activate(api) {
api.registerViewer('json-inspector', DefaultViewerComponent);
api.registerViewer('json-inspector', PreviewViewerComponent);
api.registerViewer('code-exporter', CodeViewerComponent);
},
};