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
99 changes: 97 additions & 2 deletions apps/studio/src/__tests__/integration/e2e-platform-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { PlatformFieldAdapter } from '@thingsvis/kernel';

type PlatformWriteMessage = {
type: 'tv:platform-write';
requestId: string;
payload: {
dataSourceId: string;
data: Record<string, unknown>;
Expand Down Expand Up @@ -240,14 +241,23 @@ describe('E2E-04: 反向写回 (tv:platform-write)', () => {
capturedMessages.push(msg);
});

await adapter.write({ switch: true });
const writePromise = adapter.write({ switch: true });

expect(capturedMessages).toHaveLength(1);
const msg = capturedMessages[0] as PlatformWriteMessage;
expect(msg.type).toBe('tv:platform-write');
expect(msg.requestId).toEqual(expect.any(String));
expect(msg.payload.dataSourceId).toBe('__platform__');
expect(msg.payload.data).toEqual({ switch: true });

simulateHostMessage({
type: 'tv:platform-write-result',
requestId: msg.requestId,
success: true,
echo: { accepted: true },
});
await expect(writePromise).resolves.toEqual({ success: true, echo: { accepted: true } });

spy.mockRestore();
await adapter.disconnect();
});
Expand All @@ -272,18 +282,103 @@ describe('E2E-04: 反向写回 (tv:platform-write)', () => {
capturedMessages.push(msg);
});

await adapter.write(false);
const writePromise = adapter.write(false);

expect(capturedMessages).toHaveLength(1);
const msg = capturedMessages[0] as PlatformWriteMessage & {
payload: PlatformWriteMessage['payload'] & { deviceId?: string };
};
expect(msg.type).toBe('tv:platform-write');
expect(msg.requestId).toEqual(expect.any(String));
expect(msg.payload.dataSourceId).toBe('__platform_device-1__');
expect(msg.payload.deviceId).toBe('device-1');
expect(msg.payload.data).toEqual({ switch: false });

simulateHostMessage({
type: 'tv:platform-write-result',
requestId: msg.requestId,
success: true,
});
await expect(writePromise).resolves.toMatchObject({ success: true });

spy.mockRestore();
await adapter.disconnect();
});

it('E2E-04c: write() resolves failure when host returns an error', async () => {
const adapter = new PlatformFieldAdapter();
await adapter.connect({
id: '__platform__',
type: 'PLATFORM_FIELD',
name: 'Platform',
config: { source: 'platform', fieldMappings: {}, bufferSize: 0 },
});

const capturedMessages: unknown[] = [];
const spy = vi.spyOn(window, 'postMessage').mockImplementation((msg) => {
capturedMessages.push(msg);
});

const writePromise = adapter.write({ switch: true });
const msg = capturedMessages[0] as PlatformWriteMessage;
simulateHostMessage({
type: 'tv:platform-write-result',
requestId: msg.requestId,
success: false,
error: 'permission denied',
});

await expect(writePromise).resolves.toMatchObject({
success: false,
error: 'permission denied',
});

spy.mockRestore();
await adapter.disconnect();
});

it('E2E-04d: write() times out when host does not return a result', async () => {
vi.useFakeTimers();
const adapter = new PlatformFieldAdapter();
await adapter.connect({
id: '__platform__',
type: 'PLATFORM_FIELD',
name: 'Platform',
config: { source: 'platform', fieldMappings: {}, bufferSize: 0 },
});

const spy = vi.spyOn(window, 'postMessage').mockImplementation(() => {});
const writePromise = adapter.write({ switch: true });

await vi.advanceTimersByTimeAsync(5000);
await expect(writePromise).resolves.toMatchObject({
success: false,
error: 'Platform write timed out after 5s',
});

spy.mockRestore();
await adapter.disconnect();
vi.useRealTimers();
});

it('E2E-04e: disconnect() clears pending write requests', async () => {
const adapter = new PlatformFieldAdapter();
await adapter.connect({
id: '__platform__',
type: 'PLATFORM_FIELD',
name: 'Platform',
config: { source: 'platform', fieldMappings: {}, bufferSize: 0 },
});

const spy = vi.spyOn(window, 'postMessage').mockImplementation(() => {});
const writePromise = adapter.write({ switch: true });

await adapter.disconnect();
await expect(writePromise).resolves.toMatchObject({
success: false,
error: 'PlatformFieldAdapter disconnected before write result',
});

spy.mockRestore();
});
});
4 changes: 4 additions & 0 deletions apps/studio/src/components/CanvasView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,8 @@ const CanvasView = forwardRef<
...resolveInitialWidgetProps({
schema: moduleDefs?.schema,
standaloneDefaults: moduleDefs?.standaloneDefaults,
previewDefaults: moduleDefs?.previewDefaults,
sampleData: moduleDefs?.sampleData,
fallbackDefaults: entry?.defaultProps,
}),
...(snippetEntry?.props && typeof snippetEntry.props === 'object'
Expand Down Expand Up @@ -729,6 +731,8 @@ const CanvasView = forwardRef<
props: resolveInitialWidgetProps({
schema: moduleDefs?.schema,
standaloneDefaults: moduleDefs?.standaloneDefaults,
previewDefaults: moduleDefs?.previewDefaults,
sampleData: moduleDefs?.sampleData,
fallbackDefaults: moduleDefs?.defaultProps,
}),
grid: {
Expand Down
72 changes: 64 additions & 8 deletions apps/studio/src/components/Modals/DataSourceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ import { TransformationEditor } from '../DataSourceConfig/TransformationEditor';
import CodeMirror from '@uiw/react-codemirror';
import { json } from '@codemirror/lang-json';
import { useTranslation } from 'react-i18next';
import { resolveEditorServiceConfig } from '@/lib/embedded/service-config';
import {
listEmbeddedProviderDataSourceIds,
resolveEmbeddedProviderCatalog,
} from '@/lib/embedded/embedded-data-source-registry';
import { resolveControlText } from '@/lib/i18n/controlText';

// Default configurations for new data sources
const DEFAULT_REST_CONFIG: RESTConfig = {
Expand Down Expand Up @@ -53,7 +59,7 @@ interface DataSourceDialogProps {
}

export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialogProps) {
const { t } = useTranslation('editor');
const { t, i18n } = useTranslation('editor');
const { states } = useDataSourceRegistry(store);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
Expand All @@ -73,6 +79,36 @@ export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialog
transformation: '',
});
const jsonExtensions = useMemo(() => [json()], []);
const serviceConfig = useMemo(() => resolveEditorServiceConfig(), []);
const providerDataSourceNameMap = useMemo(() => {
const catalog = resolveEmbeddedProviderCatalog(serviceConfig.provider);
if (!catalog) return new Map<string, string>();

const locale = i18n.resolvedLanguage ?? i18n.language;
return new Map(
catalog.dataSources.map((source) => [
source.id,
resolveControlText(source.label, locale, t as any),
]),
);
}, [i18n.language, i18n.resolvedLanguage, serviceConfig.provider, t]);
const protectedDataSourceIds = useMemo(() => {
if (serviceConfig.mode !== 'embedded') return new Set<string>();

const groups =
serviceConfig.context === 'dashboard'
? ['dashboard']
: serviceConfig.context === 'device-template'
? ['dashboard', 'current-device', 'current-device-history']
: undefined;

return new Set(
listEmbeddedProviderDataSourceIds(
serviceConfig.provider,
groups ? { groups: groups as any } : undefined,
),
);
}, [serviceConfig.context, serviceConfig.mode, serviceConfig.provider]);

const syncStaticJsonTextFromConfig = (configValue: unknown) => {
try {
Expand All @@ -84,6 +120,12 @@ export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialog
}
};

const getDisplayName = (dataSourceId: string): string => {
const config = dataSourceManager.getConfig(dataSourceId);
if (config?.name && config.name !== dataSourceId) return config.name;
return providerDataSourceNameMap.get(dataSourceId) ?? dataSourceId;
};

// 验证数据源 ID 格式(只允许字母、数字和下划线)
const isValidDataSourceId = (id: string) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(id);

Expand Down Expand Up @@ -143,6 +185,7 @@ export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialog

const deleteSource = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (protectedDataSourceIds.has(id)) return;
await dataSourceManager.unregisterDataSource(id);
if (selectedId === id) setSelectedId(null);
};
Expand Down Expand Up @@ -199,14 +242,27 @@ export function DataSourceDialog({ open, onOpenChange, store }: DataSourceDialog
<div
className={`w-1.5 h-1.5 rounded-full shrink-0 ${ds.status === 'connected' ? 'bg-green-400 shadow-[0_0_6px_rgba(74,222,128,0.6)]' : 'bg-yellow-400'}`}
/>
<span className="text-sm font-semibold truncate">{ds.id}</span>
<div className="min-w-0 flex flex-col">
<span className="text-sm font-semibold truncate">
{getDisplayName(ds.id)}
</span>
{getDisplayName(ds.id) !== ds.id ? (
<span
className={`text-[10px] truncate ${selectedId === ds.id ? 'text-white/60' : 'text-muted-foreground'}`}
>
{ds.id}
</span>
) : null}
</div>
</div>
<button
onClick={(e) => deleteSource(e, ds.id)}
className={`opacity-0 group-hover:opacity-100 p-1 rounded transition-all ${selectedId === ds.id ? 'hover:bg-white/20 text-white' : 'hover:bg-red-500/10 text-destructive'}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{!protectedDataSourceIds.has(ds.id) && (
<button
onClick={(e) => deleteSource(e, ds.id)}
className={`opacity-0 group-hover:opacity-100 p-1 rounded transition-all ${selectedId === ds.id ? 'hover:bg-white/20 text-white' : 'hover:bg-red-500/10 text-destructive'}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{Object.keys(states).length === 0 && (
Expand Down
Loading
Loading