diff --git a/extensions/dashboard/packages/dashboard-vue/src/components/ViewerWidget.vue b/extensions/dashboard/packages/dashboard-vue/src/components/ViewerWidget.vue
index 8287757c2..7427904d5 100644
--- a/extensions/dashboard/packages/dashboard-vue/src/components/ViewerWidget.vue
+++ b/extensions/dashboard/packages/dashboard-vue/src/components/ViewerWidget.vue
@@ -1,16 +1,16 @@
diff --git a/extensions/dashboard/packages/dashboard-vue/test/components.test.ts b/extensions/dashboard/packages/dashboard-vue/test/components.test.ts
index e2c8f9637..4151d2e38 100644
--- a/extensions/dashboard/packages/dashboard-vue/test/components.test.ts
+++ b/extensions/dashboard/packages/dashboard-vue/test/components.test.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { defineComponent } from 'vue';
+import { defineComponent, h } from 'vue';
import { flushPromises, mount } from '@vue/test-utils';
-import { DynamiaDashboard, KpiWidget, WidgetRendererRegistry, registerBuiltinWidgets } from '../src/index.js';
+import { DynamiaDashboard, KpiWidget, ViewerWidget, WidgetRendererRegistry, registerBuiltinWidgets } from '../src/index.js';
import { dashboardDescriptor, makeClient, widgetResponse } from './helpers.js';
const chartCtor = vi.fn();
@@ -139,3 +139,23 @@ describe('built-in widgets', () => {
expect(chartDestroy).toHaveBeenCalled();
});
});
+
+describe('ViewerWidget', () => {
+ const Viewer = defineComponent({ props: ['descriptorId', 'viewType', 'source', 'value', 'readOnly'], setup: () => () => h('div', { 'data-testid': 'viewer' }) });
+ const mountWidget = (data: unknown) => mount(ViewerWidget, { props: { data: data as never }, global: { stubs: { DynamiaViewer: Viewer } } });
+
+ it('gives a list value to the viewer as its data source', () => {
+ const rows = [{ a: 1 }, { a: 2 }];
+ const props = mountWidget({ descriptorId: 'clientesWidget', value: rows }).findComponent(Viewer).props();
+
+ expect(props).toMatchObject({ descriptorId: 'clientesWidget', source: rows, readOnly: true });
+ expect(props.value).toBeUndefined();
+ });
+
+ it('gives any other value to the viewer as its value', () => {
+ const props = mountWidget({ viewType: 'form', value: { name: 'Ana' } }).findComponent(Viewer).props();
+
+ expect(props).toMatchObject({ viewType: 'form', value: { name: 'Ana' } });
+ expect(props.source).toBeUndefined();
+ });
+});
diff --git a/platform/packages/ui-core/src/index.ts b/platform/packages/ui-core/src/index.ts
index 3064a472a..99b905484 100644
--- a/platform/packages/ui-core/src/index.ts
+++ b/platform/packages/ui-core/src/index.ts
@@ -73,6 +73,8 @@ export {
currencyConverter, currencySimpleConverter, decimalConverter, dateConverter, dateTimeConverter,
entityDisplayLabel, resolveConverter, builtinConverters,
} from './utils/converters.js';
+export { normalizeViewDescriptor, defineViewDescriptor } from './utils/descriptors.js';
+export type { ViewDescriptorInput, ViewFieldInput } from './utils/descriptors.js';
export { resolveIconClass } from './utils/icons.js';
export { requiredValidator, constraintValidator, builtinValidators } from './utils/validators.js';
diff --git a/platform/packages/ui-core/src/utils/descriptors.ts b/platform/packages/ui-core/src/utils/descriptors.ts
new file mode 100644
index 000000000..e4d1dc796
--- /dev/null
+++ b/platform/packages/ui-core/src/utils/descriptors.ts
@@ -0,0 +1,60 @@
+// Authoring helpers: build a ViewDescriptor from a plain JS/TS object
+
+import type { ViewDescriptor, ViewField } from '@dynamia-tools/sdk';
+
+/**
+ * A field in a {@link ViewDescriptorInput}: just its name, or a partial {@link ViewField}.
+ */
+export type ViewFieldInput = string | (Partial & { name: string });
+
+/**
+ * A descriptor written by hand. It has the shape of a backend {@link ViewDescriptor} (the same structure as a YAML view
+ * descriptor), but only `view` is required. `fields` can be an array of names / partial fields, or a record keyed by
+ * field name as in the YAML descriptors (`{ name: { label: 'Name' }, total: {} }`).
+ */
+export type ViewDescriptorInput =
+ Partial> & {
+ /** View type name: `table`, `form`, `tree`, … */
+ view: string;
+ fields?: ViewFieldInput[] | Record, 'name'> | null | undefined>;
+ };
+
+/**
+ * Builds a complete {@link ViewDescriptor} from a hand-written one, so a view can be rendered from plain JS/TS without a
+ * backend descriptor. Missing parts get defaults: `id` (`inline-`), `beanClass` (empty), `params` (empty) and, for
+ * every field, `visible: true`. A descriptor that is already complete is returned with the same content.
+ *
+ * @param input - The descriptor-shaped object
+ * @returns A new, complete descriptor (the input is not modified)
+ *
+ * @example
+ * const descriptor = normalizeViewDescriptor({
+ * view: 'table',
+ * fields: { name: { label: 'Name' }, total: { params: { converter: 'converters.Currency' } } },
+ * });
+ */
+export function normalizeViewDescriptor(input: ViewDescriptorInput): ViewDescriptor {
+ const { fields, ...rest } = input;
+ return {
+ ...rest,
+ id: input.id ?? `inline-${input.view}`,
+ beanClass: input.beanClass ?? '',
+ view: input.view,
+ params: input.params ?? {},
+ fields: normalizeFields(fields),
+ };
+}
+
+/** Authoring alias of {@link normalizeViewDescriptor}: declares a descriptor and returns it complete. */
+export const defineViewDescriptor = normalizeViewDescriptor;
+
+function normalizeFields(fields: ViewDescriptorInput['fields']): ViewField[] {
+ if (!fields) return [];
+ const list: ViewFieldInput[] = Array.isArray(fields)
+ ? fields
+ : Object.entries(fields).map(([name, field]) => ({ ...(field ?? {}), name }));
+ return list.map((field) => {
+ const f = typeof field === 'string' ? { name: field } : field;
+ return { ...f, visible: f.visible ?? true };
+ });
+}
diff --git a/platform/packages/ui-core/src/viewer/Viewer.ts b/platform/packages/ui-core/src/viewer/Viewer.ts
index 1ac1fe84d..1155d9f12 100644
--- a/platform/packages/ui-core/src/viewer/Viewer.ts
+++ b/platform/packages/ui-core/src/viewer/Viewer.ts
@@ -6,6 +6,8 @@ import { ViewTypes } from '../view/ViewType.js';
import type { View, EventHandler } from '../view/View.js';
import type { ActionMetadata } from '@dynamia-tools/sdk';
import { ViewRendererRegistry } from './ViewRendererRegistry.js';
+import { normalizeViewDescriptor } from '../utils/descriptors.js';
+import type { ViewDescriptorInput } from '../utils/descriptors.js';
/** Configuration options for Viewer initialization */
export interface ViewerConfig {
@@ -13,9 +15,12 @@ export interface ViewerConfig {
viewType?: string | ViewType | null;
/** Entity id: the entity class simple name (e.g. 'Book') */
beanClass?: string | null;
- /** Pre-loaded view descriptor (skips fetch) */
- descriptor?: ViewDescriptor | null;
- /** Descriptor ID to fetch from backend */
+ /**
+ * Pre-loaded view descriptor (skips fetch). It can be a hand-written object with the shape of a descriptor — only `view` is
+ * required, see {@link ViewDescriptorInput} — and is completed with defaults.
+ */
+ descriptor?: ViewDescriptor | ViewDescriptorInput | null;
+ /** Descriptor ID to fetch from backend: any descriptor served by `/apps/api/metadata/views/{id}`, entity-bound or not */
descriptorId?: string | null;
/** Initial value for the view */
value?: unknown;
@@ -41,7 +46,7 @@ export interface ViewerConfig {
export class Viewer {
viewType: string | ViewType | null;
beanClass: string | null;
- descriptor: ViewDescriptor | null;
+ descriptor: ViewDescriptor | ViewDescriptorInput | null;
descriptorId: string | null;
value: unknown;
source: unknown;
@@ -142,6 +147,12 @@ export class Viewer {
this._initialized = false;
}
+ /** Replace the data source of the view (rows of a table, …) */
+ setSource(source: unknown): void {
+ this.source = source;
+ if (this._view) this._view.setSource(source);
+ }
+
/** Get the primary value from the view */
getValue(): unknown { return this._view?.getValue(); }
@@ -199,7 +210,7 @@ export class Viewer {
private async _resolveDescriptor(): Promise {
if (this.descriptor) {
// Use pre-loaded descriptor directly
- this._resolvedDescriptor = this.descriptor;
+ this._resolvedDescriptor = normalizeViewDescriptor(this.descriptor);
return;
}
if (!this.client) {
@@ -207,7 +218,16 @@ export class Viewer {
throw new Error('Either provide a descriptor directly or a DynamiaClient to fetch it from the backend');
}
if (this.descriptorId) {
- // Fetch by descriptor ID:
+ // Fetch by descriptor ID. /views/{id} serves any descriptor, entity-bound or not (dashboard widgets, …).
+ try {
+ this._resolvedDescriptor = await this.client.metadata.getView(this.descriptorId);
+ } catch (_e) {
+ // Not served by id (older backend): fall back to the entity descriptors below
+ }
+ if (this._resolvedDescriptor) {
+ if (!this.beanClass && this._resolvedDescriptor.beanClass) this.beanClass = this._resolvedDescriptor.beanClass;
+ return;
+ }
// ViewDescriptorMetadata.descriptor is @JsonIgnore in Java — the actual
// ViewDescriptor content must be fetched via the /views endpoint.
// We match on d.id (the ViewDescriptorMetadata lightweight id field)
diff --git a/platform/packages/ui-core/test/utils/descriptors.test.ts b/platform/packages/ui-core/test/utils/descriptors.test.ts
new file mode 100644
index 000000000..9d2da97e9
--- /dev/null
+++ b/platform/packages/ui-core/test/utils/descriptors.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import type { ViewDescriptor } from '@dynamia-tools/sdk';
+import { defineViewDescriptor, normalizeViewDescriptor } from '../../src/utils/descriptors.js';
+
+describe('normalizeViewDescriptor', () => {
+ it('fills the defaults of a minimal descriptor', () => {
+ expect(normalizeViewDescriptor({ view: 'table' })).toEqual({
+ id: 'inline-table', beanClass: '', view: 'table', params: {}, fields: [],
+ });
+ });
+
+ it('accepts fields as names, partial fields or a record keyed by name (YAML shape)', () => {
+ const fromArray = normalizeViewDescriptor({ view: 'table', fields: ['name', { name: 'total', label: 'Total' }] });
+ expect(fromArray.fields).toEqual([{ name: 'name', visible: true }, { name: 'total', label: 'Total', visible: true }]);
+
+ const fromRecord = normalizeViewDescriptor({
+ view: 'table',
+ fields: { name: { label: 'Name' }, total: { params: { converter: 'converters.Currency' } }, note: null },
+ });
+ expect(fromRecord.fields).toEqual([
+ { name: 'name', label: 'Name', visible: true },
+ { name: 'total', params: { converter: 'converters.Currency' }, visible: true },
+ { name: 'note', visible: true },
+ ]);
+ });
+
+ it('keeps what the caller declared', () => {
+ const d = normalizeViewDescriptor({ id: 'mine', beanClass: 'Book', view: 'form', params: { a: 1 }, fields: [{ name: 'x', visible: false }] });
+ expect(d).toMatchObject({ id: 'mine', beanClass: 'Book', view: 'form', params: { a: 1 } });
+ expect(d.fields).toEqual([{ name: 'x', visible: false }]);
+ });
+
+ it('returns the same content for a complete descriptor and does not mutate the input', () => {
+ const complete: ViewDescriptor = { id: 'x', beanClass: 'Book', view: 'table', params: {}, fields: [{ name: 'a', visible: true }] };
+ const copy = JSON.parse(JSON.stringify(complete));
+ expect(normalizeViewDescriptor(complete)).toEqual(complete);
+ expect(complete).toEqual(copy);
+ });
+
+ it('defineViewDescriptor is the authoring alias', () => {
+ expect(defineViewDescriptor({ view: 'tree' }).id).toBe('inline-tree');
+ });
+});
diff --git a/platform/packages/ui-core/test/viewer/Viewer.test.ts b/platform/packages/ui-core/test/viewer/Viewer.test.ts
new file mode 100644
index 000000000..f748a2aee
--- /dev/null
+++ b/platform/packages/ui-core/test/viewer/Viewer.test.ts
@@ -0,0 +1,85 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { DynamiaClient, ViewDescriptor } from '@dynamia-tools/sdk';
+import { Viewer } from '../../src/viewer/Viewer.js';
+import { ViewRendererRegistry } from '../../src/viewer/ViewRendererRegistry.js';
+import { View } from '../../src/view/View.js';
+import type { ViewType } from '../../src/view/ViewType.js';
+
+class ProbeView extends View {
+ source: unknown;
+ constructor(type: ViewType, descriptor: ViewDescriptor) { super(type, descriptor, null); }
+ async initialize(): Promise {}
+ validate(): boolean { return true; }
+ override setSource(source: unknown): void { this.source = source; }
+}
+
+const widget: ViewDescriptor = { id: 'clientesWidget', beanClass: '', view: 'table', params: {}, fields: [{ name: 'alias', visible: true }] };
+
+function client(over: Record = {}) {
+ return {
+ metadata: {
+ getView: vi.fn().mockResolvedValue(widget),
+ getEntities: vi.fn().mockResolvedValue({ entities: [] }),
+ getEntityView: vi.fn(),
+ getEntityViews: vi.fn(),
+ ...over,
+ },
+ } as unknown as DynamiaClient & { metadata: Record> };
+}
+
+beforeEach(() => {
+ ViewRendererRegistry.registerViewFactory({ name: 'table' }, (descriptor) => new ProbeView({ name: 'table' }, descriptor));
+});
+afterEach(() => ViewRendererRegistry.clear());
+
+describe('Viewer descriptor resolution', () => {
+ it('resolves a descriptor that is not bound to an entity by its id', async () => {
+ const c = client();
+ const viewer = new Viewer({ descriptorId: 'clientesWidget', client: c, source: [{ alias: 'Ana' }] });
+
+ await viewer.initialize();
+
+ expect(c.metadata['getView']).toHaveBeenCalledWith('clientesWidget');
+ expect(c.metadata['getEntities']).not.toHaveBeenCalled();
+ expect(viewer.resolvedDescriptor).toBe(widget);
+ expect((viewer.view as ProbeView).source).toEqual([{ alias: 'Ana' }]);
+ });
+
+ it('falls back to the entity descriptors when the id is not served by /views', async () => {
+ const entityDescriptor = { ...widget, id: 'bookTable', beanClass: 'Book' };
+ const c = client({
+ getView: vi.fn().mockRejectedValue(new Error('404')),
+ getEntities: vi.fn().mockResolvedValue({ entities: [{ id: 'Book', actions: [], descriptors: [{ id: 'bookTable', view: 'table' }] }] }),
+ getEntityView: vi.fn().mockResolvedValue(entityDescriptor),
+ });
+ const viewer = new Viewer({ descriptorId: 'bookTable', client: c });
+
+ await viewer.initialize();
+
+ expect(viewer.resolvedDescriptor).toBe(entityDescriptor);
+ expect(viewer.beanClass).toBe('Book');
+ });
+
+ it('fails with not found when no source has the id', async () => {
+ const c = client({ getView: vi.fn().mockRejectedValue(new Error('404')) });
+ await expect(new Viewer({ descriptorId: 'nope', client: c }).initialize()).rejects.toThrow("Descriptor with id 'nope' not found");
+ });
+
+ it('renders a plain descriptor-shaped object without a client', async () => {
+ const viewer = new Viewer({ descriptor: { view: 'table', fields: { name: { label: 'Name' } } }, source: [1, 2] });
+
+ await viewer.initialize();
+
+ expect(viewer.resolvedDescriptor).toMatchObject({ id: 'inline-table', beanClass: '', view: 'table', fields: [{ name: 'name', label: 'Name', visible: true }] });
+ expect((viewer.view as ProbeView).source).toEqual([1, 2]);
+ });
+
+ it('setSource replaces the source of a live view', async () => {
+ const viewer = new Viewer({ descriptor: { view: 'table' } });
+ await viewer.initialize();
+
+ viewer.setSource(['x']);
+
+ expect((viewer.view as ProbeView).source).toEqual(['x']);
+ });
+});
diff --git a/platform/packages/vue/README.md b/platform/packages/vue/README.md
index fdf7811c6..25effe749 100644
--- a/platform/packages/vue/README.md
+++ b/platform/packages/vue/README.md
@@ -129,11 +129,18 @@ const { viewer, loading, error } = useViewer({
-
-
+
+
+
-
+
+
+
+
@@ -155,10 +162,15 @@ const { viewer, loading, error } = useViewer({
|------|------|-------------|
| `viewType` | `string` | View type name: `'form'`, `'table'`, `'crud'`, `'tree'`, `'kanban'`, … |
| `beanClass` | `string` | Entity id (class simple name) |
-| `descriptor` | `ViewDescriptor` | Pre-loaded descriptor (skips backend fetch) |
+| `descriptor` | `ViewDescriptor \| ViewDescriptorInput` | Pre-loaded descriptor (skips backend fetch). A hand-written object is completed with defaults: only `view` is required, `fields` may be names, partial fields or a record keyed by field name (the YAML shape) — see `normalizeViewDescriptor` in `@dynamia-tools/ui-core` |
| `descriptorId` | `string` | Descriptor ID to fetch from backend |
+| `client` | `DynamiaClient` | Client used to fetch descriptors; defaults to the one provided to `DynamiaVue` |
+| `source` | `unknown` | Data source of the view (the rows of a table); changes are applied to the live view |
+| `value` | `unknown` | Primary value of the view (the object of a form); changes are applied to the live view |
| `readOnly` | `boolean` | Propagates to the inner view |
+The viewer is rebuilt when `descriptor` (by content), `descriptorId`, `viewType`, `beanClass` or `client` change.
+
**Events:**
| Event | Payload | Description |
diff --git a/platform/packages/vue/package.json b/platform/packages/vue/package.json
index 3445e731d..f923f6238 100644
--- a/platform/packages/vue/package.json
+++ b/platform/packages/vue/package.json
@@ -60,6 +60,8 @@
"@dynamia-tools/ui-core": "workspace:*",
"@types/node": "^26.4.1",
"@vitejs/plugin-vue": "^6.0.8",
+ "@vue/test-utils": "^2.5.1",
+ "happy-dom": "^20.14.5",
"typescript": "^6.0.3",
"vite": "^8.2.2",
"vite-plugin-dts": "^5.1.0",
diff --git a/platform/packages/vue/src/components/Viewer.test.ts b/platform/packages/vue/src/components/Viewer.test.ts
new file mode 100644
index 000000000..9109f2439
--- /dev/null
+++ b/platform/packages/vue/src/components/Viewer.test.ts
@@ -0,0 +1,106 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { defineComponent, h, ref } from 'vue';
+import { flushPromises, mount } from '@vue/test-utils';
+import type { DynamiaClient, ViewDescriptor } from '@dynamia-tools/sdk';
+import { View, ViewRendererRegistry } from '@dynamia-tools/ui-core';
+import type { ViewType } from '@dynamia-tools/ui-core';
+import Viewer from './Viewer.vue';
+import { DYNAMIA_CLIENT_KEY } from '../composables/useDynamiaClient.js';
+
+const TABLE: ViewType = { name: 'table' };
+
+class ProbeView extends View {
+ // reactive, like the rows of VueTableView
+ rows = ref([]);
+ constructor(descriptor: ViewDescriptor) { super(TABLE, descriptor, null); }
+ async initialize(): Promise {}
+ validate(): boolean { return true; }
+ override setSource(source: unknown): void { this.rows.value = (source as unknown[]) ?? []; }
+ override getSource(): unknown { return this.rows.value; }
+}
+
+// The probe component shows the descriptor id and the rows of the view it is given.
+const Probe = defineComponent({
+ props: { view: { type: Object, required: true }, readOnly: Boolean },
+ setup: (props) => () => h('div', { 'data-testid': 'probe' }, `${(props.view as ProbeView).descriptor.id}:${((props.view as ProbeView).getSource() as unknown[]).length}`),
+});
+
+const widget: ViewDescriptor = { id: 'clientesWidget', beanClass: '', view: 'table', params: {}, fields: [] };
+const client = (getView = vi.fn().mockResolvedValue(widget)) => ({ metadata: { getView } }) as unknown as DynamiaClient;
+
+beforeEach(() => {
+ ViewRendererRegistry.registerViewFactory(TABLE, (descriptor) => new ProbeView(descriptor));
+ ViewRendererRegistry.register(TABLE, { render: () => Probe } as never);
+});
+afterEach(() => ViewRendererRegistry.clear());
+
+describe('DynamiaViewer', () => {
+ it('renders a descriptor written as a plain object, with no client', async () => {
+ const wrapper = mount(Viewer, { props: { descriptor: { view: 'table', fields: ['a'] }, source: [1, 2, 3] } });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid=probe]').text()).toBe('inline-table:3');
+ });
+
+ it('resolves a descriptor id with the client prop', async () => {
+ const getView = vi.fn().mockResolvedValue(widget);
+ const wrapper = mount(Viewer, { props: { descriptorId: 'clientesWidget', client: client(getView), source: [1] } });
+ await flushPromises();
+
+ expect(getView).toHaveBeenCalledWith('clientesWidget');
+ expect(wrapper.find('[data-testid=probe]').text()).toBe('clientesWidget:1');
+ });
+
+ it('resolves a descriptor id with the client provided to the app', async () => {
+ const getView = vi.fn().mockResolvedValue(widget);
+ const wrapper = mount(Viewer, { props: { descriptorId: 'clientesWidget' }, global: { provide: { [DYNAMIA_CLIENT_KEY as symbol]: client(getView) } } });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid=probe]').text()).toBe('clientesWidget:0');
+ });
+
+ it('applies a new source to the live view', async () => {
+ const wrapper = mount(Viewer, { props: { descriptor: { view: 'table' }, source: [1] } });
+ await flushPromises();
+
+ await wrapper.setProps({ source: [1, 2, 3, 4] });
+
+ expect(wrapper.find('[data-testid=probe]').text()).toBe('inline-table:4');
+ });
+
+ it('loads the other descriptor when descriptorId changes, and ignores a stale load', async () => {
+ const other = { ...widget, id: 'otro' };
+ const getView = vi.fn(async (id: string) => (id === 'otro' ? other : widget));
+ const wrapper = mount(Viewer, { props: { descriptorId: 'clientesWidget', client: client(getView) } });
+ await flushPromises();
+
+ await wrapper.setProps({ descriptorId: 'otro' });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid=probe]').text()).toBe('otro:0');
+ });
+
+ it('does not reload when an inline descriptor is re-created with the same content', async () => {
+ const getView = vi.fn();
+ const ready = vi.fn();
+ const wrapper = mount(Viewer, { props: { descriptor: { view: 'table' }, client: client(getView), onReady: ready } });
+ await flushPromises();
+
+ await wrapper.setProps({ descriptor: { view: 'table' } });
+ await flushPromises();
+
+ expect(ready).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports an unknown descriptor id through the error event', async () => {
+ const onError = vi.fn();
+ const failing = client(vi.fn().mockRejectedValue(new Error('404')));
+ (failing.metadata as unknown as Record)['getEntities'] = vi.fn().mockResolvedValue({ entities: [] });
+ const wrapper = mount(Viewer, { props: { descriptorId: 'nope', client: failing, onError } });
+ await flushPromises();
+
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('.dynamia-viewer-error').exists()).toBe(true);
+ });
+});
diff --git a/platform/packages/vue/src/components/Viewer.vue b/platform/packages/vue/src/components/Viewer.vue
index b671ee4d0..fdf20688a 100644
--- a/platform/packages/vue/src/components/Viewer.vue
+++ b/platform/packages/vue/src/components/Viewer.vue
@@ -27,11 +27,13 @@
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 66a345336..045dd58b8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -355,6 +355,12 @@ importers:
'@vitejs/plugin-vue':
specifier: ^6.0.8
version: 6.0.8(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))(vue@3.5.43(typescript@6.0.3))
+ '@vue/test-utils':
+ specifier: ^2.5.1
+ version: 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@6.0.3))
+ happy-dom:
+ specifier: ^20.14.5
+ version: 20.14.5
typescript:
specifier: ^6.0.3
version: 6.0.3