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
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<!-- ViewerWidget.vue: built-in renderer of `viewer` widgets; uses the DynamiaViewer component of @dynamia-tools/vue -->
<template>
<DynamiaViewer
ref="viewer"
:descriptor-id="data?.descriptorId ?? undefined"
:view-type="data?.viewType ?? undefined"
:source="rows"
:value="single"
:read-only="true"
@ready="applyValue"
/>
</template>

<script setup lang="ts">
import { ref, resolveComponent, watch } from 'vue';
import { computed, resolveComponent } from 'vue';
import type { ViewerWidgetData } from '@dynamia-tools/dashboard-sdk';

const props = defineProps<{
Expand All @@ -20,11 +20,8 @@ const props = defineProps<{

// Registered globally by `app.use(DynamiaVue)` from @dynamia-tools/vue
const DynamiaViewer = resolveComponent('DynamiaViewer');
const viewer = ref<{ setValue(value: unknown): void } | null>(null);

function applyValue(): void {
if (props.data) viewer.value?.setValue(props.data.value);
}

watch(() => props.data?.value, applyValue);
// A list is the data source of a table/tree view (its rows); anything else is the value of a form-like view.
const rows = computed(() => (Array.isArray(props.data?.value) ? props.data.value : undefined));
const single = computed(() => (Array.isArray(props.data?.value) ? undefined : props.data?.value));
</script>
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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();
});
});
2 changes: 2 additions & 0 deletions platform/packages/ui-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
60 changes: 60 additions & 0 deletions platform/packages/ui-core/src/utils/descriptors.ts
Original file line number Diff line number Diff line change
@@ -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<ViewField> & { 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<Omit<ViewDescriptor, 'fields' | 'view'>> & {
/** View type name: `table`, `form`, `tree`, … */
view: string;
fields?: ViewFieldInput[] | Record<string, Omit<Partial<ViewField>, '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-<view>`), `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 };
});
}
32 changes: 26 additions & 6 deletions platform/packages/ui-core/src/viewer/Viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ 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 {
/** View type name or ViewType object */
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(); }

Expand Down Expand Up @@ -199,15 +210,24 @@ export class Viewer {
private async _resolveDescriptor(): Promise<void> {
if (this.descriptor) {
// Use pre-loaded descriptor directly
this._resolvedDescriptor = this.descriptor;
this._resolvedDescriptor = normalizeViewDescriptor(this.descriptor);
return;
}
if (!this.client) {
// No client and no descriptor: cannot proceed
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)
Expand Down
43 changes: 43 additions & 0 deletions platform/packages/ui-core/test/utils/descriptors.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
85 changes: 85 additions & 0 deletions platform/packages/ui-core/test/viewer/Viewer.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {}
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<string, unknown> = {}) {
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<string, ReturnType<typeof vi.fn>> };
}

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']);
});
});
20 changes: 16 additions & 4 deletions platform/packages/vue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,18 @@ const { viewer, loading, error } = useViewer({
<DynamiaViewer view-type="table" bean-class="Book" />
<DynamiaViewer view-type="crud" bean-class="Book" />

<!-- By descriptor ID (fetched by ID from backend) -->
<DynamiaViewer descriptor-id="BookCustomForm" v-model="book" />
<!-- By descriptor ID: any descriptor served by /apps/api/metadata/views/{id}, bound to an entity or not -->
<DynamiaViewer descriptor-id="BookCustomForm" :value="book" />
<DynamiaViewer descriptor-id="clientesWidget" :source="rows" :read-only="true" />

<!-- With a pre-loaded descriptor (skips network fetch) -->
<DynamiaViewer :descriptor="myDescriptor" v-model="book" :read-only="true" />
<DynamiaViewer :descriptor="myDescriptor" :value="book" :read-only="true" />

<!-- From a plain object with the shape of a descriptor (no backend, no client needed) -->
<DynamiaViewer
:descriptor="{ view: 'table', fields: { name: { label: 'Name' }, total: { params: { converter: 'converters.Currency' } } } }"
:source="rows"
/>

<!-- Custom view type registered by a third-party module -->
<DynamiaViewer view-type="kanban" bean-class="com.example.Task" />
Expand All @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions platform/packages/vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading