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