From 869c5931d003d582276313665e25095be35a0b04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:21:41 +0000 Subject: [PATCH 01/61] Initial plan From f277e63f0d3a3ec90d88199e3d623af96831f40a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:39:22 +0000 Subject: [PATCH 02/61] feat: add @dynamia-tools/ui-core package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework-agnostic TypeScript library providing the view/viewer/renderer core for Dynamia Platform with zero DOM dependencies and zero Vue/React imports. Includes: - ViewType open-extension interface with built-in ViewTypes constants - Abstract View base class with event emitter support - Concrete views: FormView, TableView, CrudView, TreeView, ConfigView, EntityPickerView - Viewer universal host (resolves ViewType → View) - ViewRendererRegistry for mapping ViewType to renderers and factories - ViewRenderer / FieldRenderer interfaces for framework adapters - FieldResolver and LayoutEngine for descriptor → grid layout computation - ActionResolver for entity action filtering - Built-in converters (currency, decimal, date) and validators (required, constraint) - All types imported from @dynamia-tools/sdk, never redefined Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- platform/packages/pnpm-lock.yaml | 21 ++ platform/packages/pnpm-workspace.yaml | 1 + platform/packages/ui-core/package.json | 38 +++ platform/packages/ui-core/src/index.ts | 48 ++++ .../ui-core/src/renderer/ViewRenderer.ts | 67 +++++ .../ui-core/src/resolvers/ActionResolver.ts | 29 ++ .../ui-core/src/resolvers/FieldResolver.ts | 97 +++++++ .../ui-core/src/resolvers/LayoutEngine.ts | 102 +++++++ .../packages/ui-core/src/types/converters.ts | 16 ++ platform/packages/ui-core/src/types/field.ts | 64 +++++ platform/packages/ui-core/src/types/layout.ts | 37 +++ platform/packages/ui-core/src/types/state.ts | 78 +++++ .../packages/ui-core/src/types/validators.ts | 15 + .../packages/ui-core/src/utils/converters.ts | 77 +++++ .../packages/ui-core/src/utils/validators.ts | 40 +++ .../packages/ui-core/src/view/ConfigView.ts | 81 ++++++ .../packages/ui-core/src/view/CrudView.ts | 108 +++++++ .../ui-core/src/view/EntityPickerView.ts | 71 +++++ .../packages/ui-core/src/view/FormView.ts | 122 ++++++++ .../packages/ui-core/src/view/TableView.ts | 154 ++++++++++ .../packages/ui-core/src/view/TreeView.ts | 58 ++++ platform/packages/ui-core/src/view/View.ts | 85 ++++++ .../packages/ui-core/src/view/ViewType.ts | 33 +++ .../src/viewer/ViewRendererRegistry.ts | 102 +++++++ .../packages/ui-core/src/viewer/Viewer.ts | 267 ++++++++++++++++++ platform/packages/ui-core/tsconfig.build.json | 11 + platform/packages/ui-core/tsconfig.json | 5 + platform/packages/ui-core/vite.config.ts | 27 ++ 28 files changed, 1854 insertions(+) create mode 100644 platform/packages/ui-core/package.json create mode 100644 platform/packages/ui-core/src/index.ts create mode 100644 platform/packages/ui-core/src/renderer/ViewRenderer.ts create mode 100644 platform/packages/ui-core/src/resolvers/ActionResolver.ts create mode 100644 platform/packages/ui-core/src/resolvers/FieldResolver.ts create mode 100644 platform/packages/ui-core/src/resolvers/LayoutEngine.ts create mode 100644 platform/packages/ui-core/src/types/converters.ts create mode 100644 platform/packages/ui-core/src/types/field.ts create mode 100644 platform/packages/ui-core/src/types/layout.ts create mode 100644 platform/packages/ui-core/src/types/state.ts create mode 100644 platform/packages/ui-core/src/types/validators.ts create mode 100644 platform/packages/ui-core/src/utils/converters.ts create mode 100644 platform/packages/ui-core/src/utils/validators.ts create mode 100644 platform/packages/ui-core/src/view/ConfigView.ts create mode 100644 platform/packages/ui-core/src/view/CrudView.ts create mode 100644 platform/packages/ui-core/src/view/EntityPickerView.ts create mode 100644 platform/packages/ui-core/src/view/FormView.ts create mode 100644 platform/packages/ui-core/src/view/TableView.ts create mode 100644 platform/packages/ui-core/src/view/TreeView.ts create mode 100644 platform/packages/ui-core/src/view/View.ts create mode 100644 platform/packages/ui-core/src/view/ViewType.ts create mode 100644 platform/packages/ui-core/src/viewer/ViewRendererRegistry.ts create mode 100644 platform/packages/ui-core/src/viewer/Viewer.ts create mode 100644 platform/packages/ui-core/tsconfig.build.json create mode 100644 platform/packages/ui-core/tsconfig.json create mode 100644 platform/packages/ui-core/vite.config.ts diff --git a/platform/packages/pnpm-lock.yaml b/platform/packages/pnpm-lock.yaml index b6dd8137..b61c3d7d 100644 --- a/platform/packages/pnpm-lock.yaml +++ b/platform/packages/pnpm-lock.yaml @@ -54,6 +54,27 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@22.19.15) + ui-core: + devDependencies: + '@dynamia-tools/sdk': + specifier: workspace:* + version: link:../sdk + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.2.0 + version: 6.4.1(@types/node@22.19.15) + vite-plugin-dts: + specifier: ^4.5.0 + version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)) + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.15) + packages: '@ampproject/remapping@2.3.0': diff --git a/platform/packages/pnpm-workspace.yaml b/platform/packages/pnpm-workspace.yaml index 8f7adc3d..9ed40f27 100644 --- a/platform/packages/pnpm-workspace.yaml +++ b/platform/packages/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - 'sdk' + - 'ui-core' allowBuilds: esbuild: true diff --git a/platform/packages/ui-core/package.json b/platform/packages/ui-core/package.json new file mode 100644 index 00000000..0b4f6885 --- /dev/null +++ b/platform/packages/ui-core/package.json @@ -0,0 +1,38 @@ +{ + "name": "@dynamia-tools/ui-core", + "version": "26.3.1", + "description": "Framework-agnostic view/viewer/renderer core for Dynamia Platform", + "keywords": ["dynamia", "ui-core", "viewer", "view", "renderer", "typescript"], + "homepage": "https://dynamia.tools", + "repository": {"type": "git", "url": "https://github.com/dynamiatools/framework.git", "directory": "platform/packages/ui-core"}, + "license": "Apache-2.0", + "author": "Dynamia Soluciones IT SAS", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": {"types": "./dist/index.d.ts", "default": "./dist/index.js"}, + "require": {"types": "./dist/index.d.cts", "default": "./dist/index.cjs"} + } + }, + "files": ["dist", "README.md", "LICENSE"], + "scripts": { + "build": "vite build", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "peerDependencies": {"@dynamia-tools/sdk": ">=26.0.0"}, + "devDependencies": { + "@dynamia-tools/sdk": "workspace:*", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vite": "^6.2.0", + "vite-plugin-dts": "^4.5.0", + "vitest": "^3.0.0" + }, + "publishConfig": {"access": "public", "registry": "https://registry.npmjs.org/"} +} diff --git a/platform/packages/ui-core/src/index.ts b/platform/packages/ui-core/src/index.ts new file mode 100644 index 00000000..f1a14f45 --- /dev/null +++ b/platform/packages/ui-core/src/index.ts @@ -0,0 +1,48 @@ +// @dynamia-tools/ui-core — Framework-agnostic view/viewer/renderer core for Dynamia Platform + +// ── Types ───────────────────────────────────────────────────────────────────── +export type { FieldComponent, ResolvedField } from './types/field.js'; +export { FieldComponent as FieldComponents } from './types/field.js'; +export type { ResolvedLayout, ResolvedGroup, ResolvedRow } from './types/layout.js'; +export type { + ViewState, FormState, TableState, CrudState, CrudMode, SortDirection, + TreeState, TreeNode, EntityPickerState, ConfigState, ConfigParameter, +} from './types/state.js'; +export type { Converter, ConverterRegistry } from './types/converters.js'; +export type { Validator, ValidatorRegistry } from './types/validators.js'; + +// ── ViewType ────────────────────────────────────────────────────────────────── +export type { ViewType } from './view/ViewType.js'; +export { ViewTypes } from './view/ViewType.js'; + +// ── View base + concrete views ──────────────────────────────────────────────── +export type { EventHandler } from './view/View.js'; +export { View } from './view/View.js'; +export { FormView } from './view/FormView.js'; +export { TableView } from './view/TableView.js'; +export { CrudView } from './view/CrudView.js'; +export { TreeView } from './view/TreeView.js'; +export { ConfigView } from './view/ConfigView.js'; +export { EntityPickerView } from './view/EntityPickerView.js'; + +// ── Viewer + Registry ───────────────────────────────────────────────────────── +export { Viewer } from './viewer/Viewer.js'; +export type { ViewerConfig } from './viewer/Viewer.js'; +export { ViewRendererRegistry } from './viewer/ViewRendererRegistry.js'; + +// ── Renderer interfaces ─────────────────────────────────────────────────────── +export type { + ViewRenderer, FormRenderer, TableRenderer, CrudRenderer, TreeRenderer, FieldRenderer, +} from './renderer/ViewRenderer.js'; + +// ── Resolvers ───────────────────────────────────────────────────────────────── +export { FieldResolver } from './resolvers/FieldResolver.js'; +export { LayoutEngine } from './resolvers/LayoutEngine.js'; +export { ActionResolver } from './resolvers/ActionResolver.js'; + +// ── Utils ───────────────────────────────────────────────────────────────────── +export { + currencyConverter, currencySimpleConverter, decimalConverter, dateConverter, dateTimeConverter, + builtinConverters, +} from './utils/converters.js'; +export { requiredValidator, constraintValidator, builtinValidators } from './utils/validators.js'; diff --git a/platform/packages/ui-core/src/renderer/ViewRenderer.ts b/platform/packages/ui-core/src/renderer/ViewRenderer.ts new file mode 100644 index 00000000..ee5a47d4 --- /dev/null +++ b/platform/packages/ui-core/src/renderer/ViewRenderer.ts @@ -0,0 +1,67 @@ +// ViewRenderer and sub-renderer interface definitions + +import type { ViewType } from '../view/ViewType.js'; +import type { View } from '../view/View.js'; +import type { FormView } from '../view/FormView.js'; +import type { TableView } from '../view/TableView.js'; +import type { CrudView } from '../view/CrudView.js'; +import type { TreeView } from '../view/TreeView.js'; +import type { ResolvedField } from '../types/field.js'; + +/** + * Generic interface for rendering a View into an output type TOutput. + * Framework adapters (Vue, React, etc.) implement this for each ViewType. + * + * @typeParam TView - The concrete View subclass this renderer handles + * @typeParam TOutput - The rendered output type (e.g. Vue Component, React element) + */ +export interface ViewRenderer { + /** The view type this renderer handles */ + readonly supportedViewType: ViewType; + /** + * Render the view into the framework-specific output. + * @param view - The view to render + * @returns Framework-specific rendered output + */ + render(view: TView): TOutput; +} + +/** + * Renderer interface for FormView. + * @typeParam TOutput - Framework-specific output type + */ +export interface FormRenderer extends ViewRenderer {} + +/** + * Renderer interface for TableView. + * @typeParam TOutput - Framework-specific output type + */ +export interface TableRenderer extends ViewRenderer {} + +/** + * Renderer interface for CrudView. + * @typeParam TOutput - Framework-specific output type + */ +export interface CrudRenderer extends ViewRenderer {} + +/** + * Renderer interface for TreeView. + * @typeParam TOutput - Framework-specific output type + */ +export interface TreeRenderer extends ViewRenderer {} + +/** + * Renderer interface for individual fields. + * @typeParam TOutput - Framework-specific output type + */ +export interface FieldRenderer { + /** The field component identifier this renderer handles */ + readonly supportedComponent: string; + /** + * Render a field in the context of a FormView. + * @param field - The resolved field to render + * @param view - The parent FormView + * @returns Framework-specific rendered output + */ + render(field: ResolvedField, view: FormView): TOutput; +} diff --git a/platform/packages/ui-core/src/resolvers/ActionResolver.ts b/platform/packages/ui-core/src/resolvers/ActionResolver.ts new file mode 100644 index 00000000..f85fb4de --- /dev/null +++ b/platform/packages/ui-core/src/resolvers/ActionResolver.ts @@ -0,0 +1,29 @@ +// ActionResolver: resolves enabled actions for a view from entity metadata + +import type { EntityMetadata, ActionMetadata } from '@dynamia-tools/sdk'; + +/** + * Resolves the list of actions available for a given entity and view type. + * + * Example: + *
{@code
+ * const actions = ActionResolver.resolveActions(entityMetadata, 'crud');
+ * }
+ */ +export class ActionResolver { + /** + * Resolve actions for an entity metadata, optionally filtered by view type context. + * @param metadata - Entity metadata containing action list + * @param viewContext - Optional view type context for filtering + * @returns Sorted list of applicable ActionMetadata + */ + static resolveActions(metadata: EntityMetadata, viewContext?: string): ActionMetadata[] { + if (!metadata.actions) return []; + return metadata.actions.filter(action => ActionResolver._isApplicable(action, viewContext)); + } + + private static _isApplicable(action: ActionMetadata, _viewContext?: string): boolean { + // In a full implementation, this would check action params for view-specific restrictions + return true; + } +} diff --git a/platform/packages/ui-core/src/resolvers/FieldResolver.ts b/platform/packages/ui-core/src/resolvers/FieldResolver.ts new file mode 100644 index 00000000..6ceeffdf --- /dev/null +++ b/platform/packages/ui-core/src/resolvers/FieldResolver.ts @@ -0,0 +1,97 @@ +// FieldResolver: resolves FieldComponent, label, required, visible, and other field attributes + +import type { ViewDescriptor, ViewField, EntityMetadata } from '@dynamia-tools/sdk'; +import { FieldComponent } from '../types/field.js'; +import type { ResolvedField } from '../types/field.js'; + +/** + * Resolves field descriptors into fully resolved ResolvedField objects. + * Handles component type inference, label generation, and default values. + * + * Example: + *
{@code
+ * const resolved = FieldResolver.resolveFields(descriptor, metadata);
+ * }
+ */ +export class FieldResolver { + /** + * Resolve all fields from a view descriptor into ResolvedField objects. + * @param descriptor - The view descriptor + * @param metadata - Optional entity metadata for additional type info + * @returns Array of resolved fields in display order + */ + static resolveFields(descriptor: ViewDescriptor, metadata: EntityMetadata | null): ResolvedField[] { + const fields = descriptor.fields ?? []; + return fields + .filter(f => f.visible !== false) + .map((field, index) => FieldResolver.resolveField(field, index, metadata)); + } + + /** + * Resolve a single field descriptor. + * @param field - The raw field descriptor + * @param index - Field index for layout positioning + * @param metadata - Optional entity metadata + * @returns A fully resolved field + */ + static resolveField(field: ViewField, index: number, _metadata: EntityMetadata | null): ResolvedField { + const params = field.params ?? {}; + const component = FieldResolver._resolveComponent(field, params); + const label = FieldResolver._resolveLabel(field); + const span = FieldResolver._resolveSpan(params); + const group = typeof params['group'] === 'string' ? params['group'] : undefined; + + const resolved: ResolvedField = { + ...field, + resolvedComponent: component, + resolvedLabel: label, + gridSpan: span, + resolvedVisible: field.visible !== false, + resolvedRequired: field.required === true, + rowIndex: 0, + colIndex: index, + }; + if (group !== undefined) resolved.group = group; + return resolved; + } + + private static _resolveComponent(field: ViewField, params: Record): FieldComponent | string { + // 1. Explicit component in params + const explicitComponent = params['component']; + if (typeof explicitComponent === 'string' && explicitComponent) return explicitComponent; + + // 2. Infer from field class + const fieldClass = field.fieldClass ?? ''; + return FieldResolver._inferComponent(fieldClass); + } + + private static _inferComponent(fieldClass: string): FieldComponent | string { + const lc = fieldClass.toLowerCase(); + if (lc === 'string' || lc === 'java.lang.string') return FieldComponent.Textbox; + if (lc === 'integer' || lc === 'int' || lc === 'java.lang.integer') return FieldComponent.Intbox; + if (lc === 'long' || lc === 'java.lang.long') return FieldComponent.Longbox; + if (lc === 'double' || lc === 'float' || lc === 'java.lang.double' || lc === 'java.lang.float') return FieldComponent.Decimalbox; + if (lc.includes('bigdecimal') || lc === 'java.math.bigdecimal') return FieldComponent.Decimalbox; + if (lc === 'boolean' || lc === 'java.lang.boolean') return FieldComponent.Checkbox; + if (lc.includes('date') || lc.includes('localdate') || lc.includes('localdatetime')) return FieldComponent.Datebox; + if (lc.includes('enum')) return FieldComponent.Combobox; + // Default: textbox for unknown types + return FieldComponent.Textbox; + } + + private static _resolveLabel(field: ViewField): string { + if (field.label) return field.label; + // Convert camelCase to Title Case + return field.name + .replace(/([A-Z])/g, ' $1') + .replace(/^./, s => s.toUpperCase()) + .trim(); + } + + private static _resolveSpan(params: Record): number { + const span = params['span']; + if (typeof span === 'number') return span; + if (typeof span === 'string') { const n = parseInt(span, 10); return isNaN(n) ? 1 : n; } + return 1; + } +} diff --git a/platform/packages/ui-core/src/resolvers/LayoutEngine.ts b/platform/packages/ui-core/src/resolvers/LayoutEngine.ts new file mode 100644 index 00000000..841b2c72 --- /dev/null +++ b/platform/packages/ui-core/src/resolvers/LayoutEngine.ts @@ -0,0 +1,102 @@ +// LayoutEngine: computes grid layout from descriptor and resolved fields + +import type { ViewDescriptor } from '@dynamia-tools/sdk'; +import type { ResolvedField } from '../types/field.js'; +import type { ResolvedLayout, ResolvedGroup, ResolvedRow } from '../types/layout.js'; + +/** + * Computes the grid layout for a form view from its descriptor and resolved fields. + * Groups fields by their group assignment and arranges them into rows and columns. + * + * Example: + *
{@code
+ * const layout = LayoutEngine.computeLayout(descriptor, resolvedFields);
+ * // layout.columns = 3
+ * // layout.groups[0].rows[0].fields = [nameField, emailField, phoneField]
+ * }
+ */ +export class LayoutEngine { + /** + * Compute the full layout for a set of resolved fields. + * @param descriptor - View descriptor containing layout params + * @param fields - Fully resolved fields from FieldResolver + * @returns Computed ResolvedLayout with groups, rows and column info + */ + static computeLayout(descriptor: ViewDescriptor, fields: ResolvedField[]): ResolvedLayout { + const params = descriptor.params ?? {}; + const columns = LayoutEngine._resolveColumns(params); + + // Group fields by their group name + const groupedFields = new Map(); + const defaultGroupKey = ''; + for (const field of fields) { + const key = field.group ?? defaultGroupKey; + if (!groupedFields.has(key)) groupedFields.set(key, []); + groupedFields.get(key)!.push(field); + } + + // Build resolved groups in insertion order + const groups: ResolvedGroup[] = []; + for (const [groupName, groupFields] of groupedFields) { + const rows = LayoutEngine._buildRows(groupFields, columns); + const groupParams = LayoutEngine._findGroupParams(descriptor, groupName); + const resolvedGroup: ResolvedGroup = { name: groupName, rows }; + const label = groupParams?.label ?? (groupName || undefined); + if (label !== undefined) resolvedGroup.label = label; + if (groupParams?.icon !== undefined) resolvedGroup.icon = groupParams.icon; + groups.push(resolvedGroup); + } + + // Update row/col indices on the fields + for (const group of groups) { + for (const row of group.rows) { + let colIdx = 0; + for (const field of row.fields) { + (field as ResolvedField).rowIndex = groups.indexOf(group) * 1000 + group.rows.indexOf(row); + (field as ResolvedField).colIndex = colIdx; + colIdx += field.gridSpan; + } + } + } + + return { columns, groups, allFields: fields }; + } + + private static _resolveColumns(params: Record): number { + const cols = params['columns']; + if (typeof cols === 'number') return cols; + if (typeof cols === 'string') { const n = parseInt(cols, 10); return isNaN(n) ? 1 : n; } + return 1; + } + + private static _buildRows(fields: ResolvedField[], columns: number): ResolvedRow[] { + const rows: ResolvedRow[] = []; + let currentRow: ResolvedField[] = []; + let currentWidth = 0; + + for (const field of fields) { + const span = Math.min(field.gridSpan, columns); + if (currentWidth + span > columns && currentRow.length > 0) { + rows.push({ fields: currentRow }); + currentRow = []; + currentWidth = 0; + } + currentRow.push({ ...field, gridSpan: span }); + currentWidth += span; + } + if (currentRow.length > 0) rows.push({ fields: currentRow }); + + return rows; + } + + private static _findGroupParams(descriptor: ViewDescriptor, groupName: string): { label?: string; icon?: string } | null { + if (!groupName) return null; + const groupsParam = descriptor.params?.['groups']; + if (groupsParam && typeof groupsParam === 'object') { + const groups = groupsParam as Record; + const gp = groups[groupName]; + if (gp && typeof gp === 'object') return gp as { label?: string; icon?: string }; + } + return null; + } +} diff --git a/platform/packages/ui-core/src/types/converters.ts b/platform/packages/ui-core/src/types/converters.ts new file mode 100644 index 00000000..7ec70349 --- /dev/null +++ b/platform/packages/ui-core/src/types/converters.ts @@ -0,0 +1,16 @@ +// Converter function signature for field value transformation + +/** + * A converter transforms a raw value into a display string. + * Used by TableView columns and FormView fields to format values. + * + * @param value - The raw value to convert + * @param params - Optional params from the field descriptor + * @returns The formatted display string + */ +export type Converter = (value: unknown, params?: Record) => string; + +/** + * Registry of named converters. Can be extended at runtime. + */ +export type ConverterRegistry = Record; diff --git a/platform/packages/ui-core/src/types/field.ts b/platform/packages/ui-core/src/types/field.ts new file mode 100644 index 00000000..e980ee8a --- /dev/null +++ b/platform/packages/ui-core/src/types/field.ts @@ -0,0 +1,64 @@ +// Field component registry and resolved field type for ui-core + +import type { ViewField } from '@dynamia-tools/sdk'; + +/** + * Maps descriptor component strings to known field component identifiers. + * Names match ZK component names exactly for vocabulary consistency. + * This is a const object, not an enum, so external modules can extend it. + */ +export const FieldComponent = { + Textbox: 'textbox', + Intbox: 'intbox', + Longbox: 'longbox', + Decimalbox: 'decimalbox', + Spinner: 'spinner', + Doublespinner: 'doublespinner', + Combobox: 'combobox', + Datebox: 'datebox', + Timebox: 'timebox', + Checkbox: 'checkbox', + EntityPicker: 'entitypicker', + EntityRefPicker: 'entityrefpicker', + EntityRefLabel: 'entityreflabel', + CrudView: 'crudview', + CoolLabel: 'coollabel', + EntityFileImage: 'entityfileimage', + Link: 'link', + EnumIconImage: 'enumiconimage', + PrinterCombobox: 'printercombobox', + ProviderMultiPickerbox: 'providermultipickerbox', + Textareabox: 'textareabox', + HtmlEditor: 'htmleditor', + ColorPicker: 'colorpicker', + NumberRangeBox: 'numberrangebox', + ImageBox: 'imagebox', + SliderBox: 'sliderbox', + Listbox: 'listbox', +} as const satisfies Record; + +/** All valid field component identifiers */ +export type FieldComponent = (typeof FieldComponent)[keyof typeof FieldComponent]; + +/** + * A fully resolved field descriptor, enriched with computed layout and component info. + * Extends the SDK ViewField with runtime-resolved values. + */ +export interface ResolvedField extends ViewField { + /** The resolved component identifier to use for rendering */ + resolvedComponent: FieldComponent | string; + /** The resolved display label (localized or default) */ + resolvedLabel: string; + /** The grid column span for layout (default 1) */ + gridSpan: number; + /** Whether this field is currently visible */ + resolvedVisible: boolean; + /** Whether this field is required */ + resolvedRequired: boolean; + /** The group name this field belongs to (if any) */ + group?: string; + /** The row index in the grid layout */ + rowIndex: number; + /** The column index in the grid layout */ + colIndex: number; +} diff --git a/platform/packages/ui-core/src/types/layout.ts b/platform/packages/ui-core/src/types/layout.ts new file mode 100644 index 00000000..34bb902a --- /dev/null +++ b/platform/packages/ui-core/src/types/layout.ts @@ -0,0 +1,37 @@ +// Layout types for grid-based form layout computation + +import type { ResolvedField } from './field.js'; + +/** + * A single row in a grid layout containing resolved fields. + */ +export interface ResolvedRow { + /** Fields in this row */ + fields: ResolvedField[]; +} + +/** + * A named group of rows, corresponding to a form fieldgroup/tab. + */ +export interface ResolvedGroup { + /** Group name (or empty string for the default group) */ + name: string; + /** Display label for the group */ + label?: string; + /** Icon for the group */ + icon?: string; + /** Ordered list of rows in this group */ + rows: ResolvedRow[]; +} + +/** + * The fully computed layout for a form or other grid-based view. + */ +export interface ResolvedLayout { + /** Total number of grid columns */ + columns: number; + /** All groups in display order (default group first if it has fields) */ + groups: ResolvedGroup[]; + /** All fields in display order (flat list, across all groups) */ + allFields: ResolvedField[]; +} diff --git a/platform/packages/ui-core/src/types/state.ts b/platform/packages/ui-core/src/types/state.ts new file mode 100644 index 00000000..78367f31 --- /dev/null +++ b/platform/packages/ui-core/src/types/state.ts @@ -0,0 +1,78 @@ +// State type definitions for all view types + +import type { CrudPageable } from '@dynamia-tools/sdk'; + +/** Generic base state shared by all views */ +export interface ViewState { + loading: boolean; + error: string | null; + initialized: boolean; +} + +/** State specific to FormView */ +export interface FormState extends ViewState { + values: Record; + errors: Record; + dirty: boolean; + submitted: boolean; +} + +/** Sort direction for TableView */ +export type SortDirection = 'asc' | 'desc' | null; + +/** State specific to TableView */ +export interface TableState extends ViewState { + rows: unknown[]; + pagination: CrudPageable | null; + sortField: string | null; + sortDir: SortDirection; + searchQuery: string; + selectedRow: unknown | null; +} + +/** CRUD interaction mode */ +export type CrudMode = 'list' | 'create' | 'edit'; + +/** State specific to CrudView */ +export interface CrudState extends ViewState { + mode: CrudMode; +} + +/** State specific to TreeView */ +export interface TreeState extends ViewState { + nodes: TreeNode[]; + expandedNodeIds: Set; + selectedNodeId: string | null; +} + +/** A single node in a tree view */ +export interface TreeNode { + id: string; + label: string; + icon?: string; + children?: TreeNode[]; + data?: unknown; +} + +/** State specific to EntityPickerView */ +export interface EntityPickerState extends ViewState { + searchQuery: string; + searchResults: unknown[]; + selectedEntity: unknown | null; +} + +/** State specific to ConfigView */ +export interface ConfigState extends ViewState { + parameters: ConfigParameter[]; + values: Record; +} + +/** A single configuration parameter */ +export interface ConfigParameter { + name: string; + label: string; + description?: string; + type: string; + defaultValue?: unknown; + required?: boolean; +} diff --git a/platform/packages/ui-core/src/types/validators.ts b/platform/packages/ui-core/src/types/validators.ts new file mode 100644 index 00000000..5271f95a --- /dev/null +++ b/platform/packages/ui-core/src/types/validators.ts @@ -0,0 +1,15 @@ +// Validator function signature for field value validation + +/** + * A validator checks a value and returns an error message string if invalid, or null if valid. + * + * @param value - The value to validate + * @param params - Optional params from the field descriptor + * @returns Error message string, or null if valid + */ +export type Validator = (value: unknown, params?: Record) => string | null; + +/** + * Registry of named validators. Can be extended at runtime. + */ +export type ValidatorRegistry = Record; diff --git a/platform/packages/ui-core/src/utils/converters.ts b/platform/packages/ui-core/src/utils/converters.ts new file mode 100644 index 00000000..b82e423c --- /dev/null +++ b/platform/packages/ui-core/src/utils/converters.ts @@ -0,0 +1,77 @@ +// Built-in value converters for display formatting + +import type { Converter } from '../types/converters.js'; + +/** + * Formats a number as currency with two decimal places and comma separators. + * @param value - Numeric value to format + * @returns Formatted currency string (e.g. "1,234.56") + */ +export const currencyConverter: Converter = (value) => { + if (value === null || value === undefined) return ''; + const num = Number(value); + if (isNaN(num)) return String(value); + return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +}; + +/** + * Formats a number as simplified currency (no cents for whole numbers). + * @param value - Numeric value to format + * @returns Simplified currency string + */ +export const currencySimpleConverter: Converter = (value) => { + if (value === null || value === undefined) return ''; + const num = Number(value); + if (isNaN(num)) return String(value); + if (num % 1 === 0) return num.toLocaleString('en-US'); + return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +}; + +/** + * Formats a number as a decimal with configurable decimal places. + * @param value - Numeric value to format + * @param params - Optional params: `decimals` (default 2) + * @returns Formatted decimal string + */ +export const decimalConverter: Converter = (value, params) => { + if (value === null || value === undefined) return ''; + const num = Number(value); + if (isNaN(num)) return String(value); + const decimals = typeof params?.['decimals'] === 'number' ? params['decimals'] : 2; + return num.toFixed(decimals); +}; + +/** + * Formats a Date or date string as a locale date string. + * @param value - Date value to format + * @returns Formatted date string (locale-dependent) + */ +export const dateConverter: Converter = (value) => { + if (value === null || value === undefined) return ''; + try { + const d = new Date(value as string | number); + return isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(); + } catch { return String(value); } +}; + +/** + * Formats a Date or date string as a locale date-time string. + * @param value - Date-time value to format + * @returns Formatted date-time string (locale-dependent) + */ +export const dateTimeConverter: Converter = (value) => { + if (value === null || value === undefined) return ''; + try { + const d = new Date(value as string | number); + return isNaN(d.getTime()) ? String(value) : d.toLocaleString(); + } catch { return String(value); } +}; + +/** Registry of all built-in converters */ +export const builtinConverters: Record = { + currency: currencyConverter, + currencySimple: currencySimpleConverter, + decimal: decimalConverter, + date: dateConverter, + dateTime: dateTimeConverter, +}; diff --git a/platform/packages/ui-core/src/utils/validators.ts b/platform/packages/ui-core/src/utils/validators.ts new file mode 100644 index 00000000..8dd6ac79 --- /dev/null +++ b/platform/packages/ui-core/src/utils/validators.ts @@ -0,0 +1,40 @@ +// Built-in field value validators + +import type { Validator } from '../types/validators.js'; + +/** + * Validates that a value is not empty/null/undefined. + * @param value - Value to check + * @returns Error message if empty, null if valid + */ +export const requiredValidator: Validator = (value) => { + if (value === null || value === undefined || value === '') return 'This field is required'; + if (typeof value === 'string' && value.trim() === '') return 'This field is required'; + return null; +}; + +/** + * Validates a value against a regex constraint. + * @param value - Value to validate + * @param params - Must include `pattern` (regex string) and optional `message` + * @returns Error message if invalid, null if valid + */ +export const constraintValidator: Validator = (value, params) => { + if (value === null || value === undefined || value === '') return null; + const pattern = params?.['pattern']; + if (typeof pattern === 'string') { + try { + const regex = new RegExp(pattern); + if (!regex.test(String(value))) { + return typeof params?.['message'] === 'string' ? params['message'] : 'Invalid value'; + } + } catch { return 'Invalid constraint pattern'; } + } + return null; +}; + +/** Registry of all built-in validators */ +export const builtinValidators: Record = { + required: requiredValidator, + constraint: constraintValidator, +}; diff --git a/platform/packages/ui-core/src/view/ConfigView.ts b/platform/packages/ui-core/src/view/ConfigView.ts new file mode 100644 index 00000000..86ea76c1 --- /dev/null +++ b/platform/packages/ui-core/src/view/ConfigView.ts @@ -0,0 +1,81 @@ +// ConfigView: module configuration parameters view + +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import { View } from './View.js'; +import { ViewTypes } from './ViewType.js'; +import type { ConfigState, ConfigParameter } from '../types/state.js'; + +/** + * View implementation for module configuration parameters. + * + * Example: + *
{@code
+ * const view = new ConfigView(descriptor, metadata);
+ * await view.initialize();
+ * await view.loadParameters();
+ * view.setParameterValue('theme', 'dark');
+ * await view.saveParameters();
+ * }
+ */ +export class ConfigView extends View { + protected override state: ConfigState; + private _loader?: () => Promise; + private _saver?: (values: Record) => Promise; + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(ViewTypes.Config, descriptor, entityMetadata); + this.state = { loading: false, error: null, initialized: false, parameters: [], values: {} }; + } + + async initialize(): Promise { + this.state.initialized = true; + } + + validate(): boolean { + for (const param of this.state.parameters) { + if (param.required === true && (this.state.values[param.name] === undefined || this.state.values[param.name] === null)) { + return false; + } + } + return true; + } + + override getValue(): Record { return { ...this.state.values }; } + + /** Set loader function for fetching parameters */ + setLoader(loader: () => Promise): void { this._loader = loader; } + + /** Set saver function for persisting parameter values */ + setSaver(saver: (values: Record) => Promise): void { this._saver = saver; } + + /** Load available parameters from backend */ + async loadParameters(): Promise { + if (!this._loader) return; + this.state.loading = true; + try { + this.state.parameters = await this._loader(); + this.state.initialized = true; + } finally { + this.state.loading = false; + } + } + + /** Save current parameter values to backend */ + async saveParameters(): Promise { + if (!this._saver) return; + this.state.loading = true; + try { + await this._saver(this.state.values); + this.emit('saved', this.state.values); + } catch (e) { + this.state.error = String(e); + this.emit('error', e); + } finally { + this.state.loading = false; + } + } + + setParameterValue(name: string, value: unknown): void { this.state.values[name] = value; } + getParameterValue(name: string): unknown { return this.state.values[name]; } + getParameters(): ConfigParameter[] { return this.state.parameters; } +} diff --git a/platform/packages/ui-core/src/view/CrudView.ts b/platform/packages/ui-core/src/view/CrudView.ts new file mode 100644 index 00000000..bed77fc9 --- /dev/null +++ b/platform/packages/ui-core/src/view/CrudView.ts @@ -0,0 +1,108 @@ +// CrudView: orchestrates FormView + TableView + action lifecycle + +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import { View } from './View.js'; +import { ViewTypes } from './ViewType.js'; +import { FormView } from './FormView.js'; +import { TableView } from './TableView.js'; +import type { CrudState, CrudMode } from '../types/state.js'; + +/** + * CRUD view that orchestrates a FormView and TableView. + * Manages the mode transitions between list, create and edit. + * + * Example: + *
{@code
+ * const view = new CrudView(descriptor, metadata);
+ * await view.initialize();
+ * view.startCreate();
+ * view.formView.setFieldValue('name', 'John');
+ * await view.save();
+ * }
+ */ +export class CrudView extends View { + protected override state: CrudState; + readonly formView: FormView; + readonly tableView: TableView; + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(ViewTypes.Crud, descriptor, entityMetadata); + this.state = { loading: false, error: null, initialized: false, mode: 'list' }; + this.formView = new FormView(descriptor, entityMetadata); + this.tableView = new TableView(descriptor, entityMetadata); + } + + async initialize(): Promise { + this.state.loading = true; + try { + await Promise.all([this.formView.initialize(), this.tableView.initialize()]); + this.state.initialized = true; + this.emit('ready'); + } catch (e) { + this.state.error = String(e); + } finally { + this.state.loading = false; + } + } + + validate(): boolean { return this.formView.validate(); } + + override getValue(): unknown { return this.formView.getValue(); } + override setValue(value: unknown): void { this.formView.setValue(value); } + + /** Get the current CRUD mode */ + getMode(): CrudMode { return this.state.mode; } + + /** Start creating a new entity */ + startCreate(): void { + this.formView.reset(); + this.state.mode = 'create'; + this.emit('mode-change', 'create'); + } + + /** Start editing an existing entity */ + startEdit(entity: unknown): void { + this.formView.setValue(entity); + this.state.mode = 'edit'; + this.emit('mode-change', 'edit'); + } + + /** Cancel edit and return to list */ + cancelEdit(): void { + this.formView.reset(); + this.state.mode = 'list'; + this.emit('mode-change', 'list'); + } + + /** Save the current entity (create or update) */ + async save(): Promise { + if (!this.formView.validate()) return; + this.state.loading = true; + try { + const data = this.formView.getValue(); + this.emit('save', { mode: this.state.mode, data }); + this.state.mode = 'list'; + this.emit('mode-change', 'list'); + await this.tableView.load(); + } catch (e) { + this.state.error = String(e); + this.emit('error', e); + } finally { + this.state.loading = false; + } + } + + /** Delete an entity */ + async delete(entity: unknown): Promise { + this.state.loading = true; + try { + this.emit('delete', entity); + await this.tableView.load(); + } catch (e) { + this.state.error = String(e); + this.emit('error', e); + } finally { + this.state.loading = false; + } + } +} diff --git a/platform/packages/ui-core/src/view/EntityPickerView.ts b/platform/packages/ui-core/src/view/EntityPickerView.ts new file mode 100644 index 00000000..2caf0059 --- /dev/null +++ b/platform/packages/ui-core/src/view/EntityPickerView.ts @@ -0,0 +1,71 @@ +// EntityPickerView: entity selector (popup/inline search) view + +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import { View } from './View.js'; +import { ViewTypes } from './ViewType.js'; +import type { EntityPickerState } from '../types/state.js'; + +/** + * View implementation for entity selection via search. + * + * Example: + *
{@code
+ * const view = new EntityPickerView(descriptor, metadata);
+ * await view.initialize();
+ * await view.search('John');
+ * view.select(results[0]);
+ * }
+ */ +export class EntityPickerView extends View { + protected override state: EntityPickerState; + private _searcher?: (query: string) => Promise; + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(ViewTypes.EntityPicker, descriptor, entityMetadata); + this.state = { loading: false, error: null, initialized: false, searchQuery: '', searchResults: [], selectedEntity: null }; + } + + async initialize(): Promise { + this.state.initialized = true; + } + + validate(): boolean { return this.state.selectedEntity !== null; } + + override getValue(): unknown { return this.state.selectedEntity; } + override setValue(value: unknown): void { this.state.selectedEntity = value; } + + /** Set a search function for querying entities */ + setSearcher(searcher: (query: string) => Promise): void { this._searcher = searcher; } + + /** Search for entities matching a query */ + async search(query: string): Promise { + this.state.searchQuery = query; + if (!this._searcher) return; + this.state.loading = true; + try { + this.state.searchResults = await this._searcher(query); + this.emit('search-results', this.state.searchResults); + } catch (e) { + this.state.error = String(e); + } finally { + this.state.loading = false; + } + } + + /** Select an entity from search results */ + select(entity: unknown): void { + this.state.selectedEntity = entity; + this.emit('select', entity); + } + + /** Clear the current selection */ + clear(): void { + this.state.selectedEntity = null; + this.state.searchQuery = ''; + this.state.searchResults = []; + this.emit('clear'); + } + + getSearchResults(): unknown[] { return this.state.searchResults; } + getSelectedEntity(): unknown { return this.state.selectedEntity; } +} diff --git a/platform/packages/ui-core/src/view/FormView.ts b/platform/packages/ui-core/src/view/FormView.ts new file mode 100644 index 00000000..6442c123 --- /dev/null +++ b/platform/packages/ui-core/src/view/FormView.ts @@ -0,0 +1,122 @@ +// FormView: handles form field logic, layout, values and validation + +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import { View } from './View.js'; +import { ViewTypes } from './ViewType.js'; +import type { ResolvedField } from '../types/field.js'; +import type { ResolvedLayout } from '../types/layout.js'; +import type { FormState } from '../types/state.js'; +import { FieldResolver } from '../resolvers/FieldResolver.js'; +import { LayoutEngine } from '../resolvers/LayoutEngine.js'; + +/** + * View implementation for rendering and managing entity forms. + * Handles field resolution, layout computation, value management and validation. + * + * Example: + *
{@code
+ * const view = new FormView(descriptor, metadata);
+ * await view.initialize();
+ * view.setFieldValue('name', 'John');
+ * view.validate();
+ * }
+ */ +export class FormView extends View { + protected override state: FormState; + + private _resolvedFields: ResolvedField[] = []; + private _layout: ResolvedLayout | null = null; + private _readOnly = false; + private _value: Record = {}; + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(ViewTypes.Form, descriptor, entityMetadata); + this.state = { + loading: false, + error: null, + initialized: false, + values: {}, + errors: {}, + dirty: false, + submitted: false, + }; + } + + async initialize(): Promise { + this.state.loading = true; + try { + this._resolvedFields = FieldResolver.resolveFields(this.descriptor, this.entityMetadata); + this._layout = LayoutEngine.computeLayout(this.descriptor, this._resolvedFields); + this.state.initialized = true; + } finally { + this.state.loading = false; + } + } + + validate(): boolean { + const errors: Record = {}; + for (const field of this._resolvedFields) { + if (field.resolvedRequired && field.resolvedVisible) { + const value = this.state.values[field.name]; + if (value === undefined || value === null || value === '') { + errors[field.name] = `${field.resolvedLabel} is required`; + } + } + } + this.state.errors = errors; + return Object.keys(errors).length === 0; + } + + override getValue(): Record { return { ...this.state.values }; } + + override setValue(value: unknown): void { + if (value && typeof value === 'object') { + this.state.values = { ...(value as Record) }; + this._value = this.state.values; + } + } + + override isReadOnly(): boolean { return this._readOnly; } + override setReadOnly(readOnly: boolean): void { this._readOnly = readOnly; } + + /** Get the value of a specific field */ + getFieldValue(field: string): unknown { return this.state.values[field]; } + + /** Set the value of a specific field and emit change event */ + setFieldValue(field: string, value: unknown): void { + this.state.values[field] = value; + this.state.dirty = true; + this.emit('change', { field, value }); + } + + /** Get all resolved fields */ + getResolvedFields(): ResolvedField[] { return this._resolvedFields; } + + /** Get the computed layout */ + getLayout(): ResolvedLayout | null { return this._layout; } + + /** Get validation errors map */ + getErrors(): Record { return { ...this.state.errors }; } + + /** Get field error message */ + getFieldError(field: string): string | undefined { return this.state.errors[field]; } + + /** Submit the form */ + async submit(): Promise { + if (this.validate()) { + this.state.submitted = true; + this.emit('submit', this.state.values); + } else { + this.emit('error', this.state.errors); + } + } + + /** Reset the form to its initial state */ + reset(): void { + this.state.values = {}; + this.state.errors = {}; + this.state.dirty = false; + this.state.submitted = false; + this.emit('reset'); + } +} diff --git a/platform/packages/ui-core/src/view/TableView.ts b/platform/packages/ui-core/src/view/TableView.ts new file mode 100644 index 00000000..4be5e3cc --- /dev/null +++ b/platform/packages/ui-core/src/view/TableView.ts @@ -0,0 +1,154 @@ +// TableView: handles tabular data, columns, pagination, sorting and search + +import type { ViewDescriptor, EntityMetadata, CrudPageable } from '@dynamia-tools/sdk'; +import { View } from './View.js'; +import { ViewTypes } from './ViewType.js'; +import type { ResolvedField } from '../types/field.js'; +import type { TableState, SortDirection } from '../types/state.js'; +import { FieldResolver } from '../resolvers/FieldResolver.js'; + +/** + * View implementation for tabular data display. + * Handles column resolution, row data, pagination, sorting and search. + * + * Example: + *
{@code
+ * const view = new TableView(descriptor, metadata);
+ * await view.initialize();
+ * await view.load();
+ * }
+ */ +export class TableView extends View { + protected override state: TableState; + + private _resolvedColumns: ResolvedField[] = []; + private _readOnly = false; + private _crudPath?: string; + private _loader?: (params: Record) => Promise<{ rows: unknown[]; pagination: CrudPageable | null }>; + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(ViewTypes.Table, descriptor, entityMetadata); + this.state = { + loading: false, + error: null, + initialized: false, + rows: [], + pagination: null, + sortField: null, + sortDir: null, + searchQuery: '', + selectedRow: null, + }; + } + + async initialize(): Promise { + this.state.loading = true; + try { + this._resolvedColumns = FieldResolver.resolveFields(this.descriptor, this.entityMetadata); + this.state.initialized = true; + } finally { + this.state.loading = false; + } + } + + validate(): boolean { return true; } + + override getValue(): unknown[] { return [...this.state.rows]; } + + override setSource(source: unknown): void { + if (Array.isArray(source)) { + this.state.rows = source; + } + } + + override isReadOnly(): boolean { return this._readOnly; } + override setReadOnly(readOnly: boolean): void { this._readOnly = readOnly; } + + /** Set the path for CRUD operations (used to build API calls) */ + setCrudPath(path: string): void { this._crudPath = path; } + getCrudPath(): string | undefined { return this._crudPath; } + + /** Set a custom loader function for fetching rows */ + setLoader(loader: (params: Record) => Promise<{ rows: unknown[]; pagination: CrudPageable | null }>): void { + this._loader = loader; + } + + /** Load data with optional query parameters */ + async load(params: Record = {}): Promise { + this.state.loading = true; + this.state.error = null; + try { + if (this._loader) { + const result = await this._loader({ ...params, ...this._buildQueryParams() }); + this.state.rows = result.rows; + this.state.pagination = result.pagination; + this.emit('load', this.state.rows); + } + } catch (e) { + this.state.error = String(e); + this.emit('error', e); + } finally { + this.state.loading = false; + } + } + + /** Go to next page */ + async nextPage(): Promise { + if (this.state.pagination && this.state.pagination.page < this.state.pagination.pagesNumber) { + await this.load({ page: this.state.pagination.page + 1 }); + } + } + + /** Go to previous page */ + async prevPage(): Promise { + if (this.state.pagination && this.state.pagination.page > 1) { + await this.load({ page: this.state.pagination.page - 1 }); + } + } + + /** Sort by a field */ + async sort(field: string): Promise { + if (this.state.sortField === field) { + this.state.sortDir = this.state.sortDir === 'asc' ? 'desc' : 'asc'; + } else { + this.state.sortField = field; + this.state.sortDir = 'asc'; + } + await this.load(); + } + + /** Search with a query string */ + async search(query: string): Promise { + this.state.searchQuery = query; + await this.load({ page: 1 }); + } + + /** Select a row */ + selectRow(row: unknown): void { + this.state.selectedRow = row; + this.emit('select', row); + } + + /** Get selected row */ + getSelectedRow(): unknown { return this.state.selectedRow; } + + /** Get resolved column definitions */ + getResolvedColumns(): ResolvedField[] { return this._resolvedColumns; } + + /** Get current rows */ + getRows(): unknown[] { return this.state.rows; } + + /** Get current pagination state */ + getPagination(): CrudPageable | null { return this.state.pagination; } + + private _buildQueryParams(): Record { + const params: Record = {}; + if (this.state.pagination) params['page'] = this.state.pagination.page; + if (this.state.sortField) { + params['sortField'] = this.state.sortField; + params['sortDir'] = this.state.sortDir ?? 'asc'; + } + if (this.state.searchQuery) params['q'] = this.state.searchQuery; + return params; + } +} diff --git a/platform/packages/ui-core/src/view/TreeView.ts b/platform/packages/ui-core/src/view/TreeView.ts new file mode 100644 index 00000000..9a74b400 --- /dev/null +++ b/platform/packages/ui-core/src/view/TreeView.ts @@ -0,0 +1,58 @@ +// TreeView: hierarchical tree data view + +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import { View } from './View.js'; +import { ViewTypes } from './ViewType.js'; +import type { TreeState, TreeNode } from '../types/state.js'; + +/** + * View implementation for hierarchical tree data. + * + * Example: + *
{@code
+ * const view = new TreeView(descriptor, metadata);
+ * await view.initialize();
+ * view.expand(node);
+ * }
+ */ +export class TreeView extends View { + protected override state: TreeState; + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(ViewTypes.Tree, descriptor, entityMetadata); + this.state = { loading: false, error: null, initialized: false, nodes: [], expandedNodeIds: new Set(), selectedNodeId: null }; + } + + async initialize(): Promise { + this.state.initialized = true; + } + + validate(): boolean { return true; } + + override getValue(): unknown { return this.state.selectedNodeId; } + override setSource(source: unknown): void { + if (Array.isArray(source)) this.state.nodes = source as TreeNode[]; + } + + /** Expand a tree node */ + expand(node: TreeNode): void { + this.state.expandedNodeIds.add(node.id); + this.emit('expand', node); + } + + /** Collapse a tree node */ + collapse(node: TreeNode): void { + this.state.expandedNodeIds.delete(node.id); + this.emit('collapse', node); + } + + /** Select a tree node */ + selectNode(node: TreeNode): void { + this.state.selectedNodeId = node.id; + this.emit('select', node); + } + + getNodes(): TreeNode[] { return this.state.nodes; } + isExpanded(node: TreeNode): boolean { return this.state.expandedNodeIds.has(node.id); } + getSelectedNodeId(): string | null { return this.state.selectedNodeId; } +} diff --git a/platform/packages/ui-core/src/view/View.ts b/platform/packages/ui-core/src/view/View.ts new file mode 100644 index 00000000..14707fce --- /dev/null +++ b/platform/packages/ui-core/src/view/View.ts @@ -0,0 +1,85 @@ +// Abstract base class for all view types in ui-core + +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import type { ViewType } from './ViewType.js'; +import type { ViewState } from '../types/state.js'; + +/** Handler function type for view events */ +export type EventHandler = (payload?: unknown) => void; + +/** + * Abstract base class for all view types. + * Mirrors the backend tools.dynamia.viewers.View contract. + * + * Example: + *
{@code
+ * class MyCustomView extends View {
+ *   async initialize() { ... }
+ *   validate() { return true; }
+ * }
+ * }
+ */ +export abstract class View { + /** The view type identity */ + readonly viewType: ViewType; + /** The view descriptor from the backend */ + readonly descriptor: ViewDescriptor; + /** Entity metadata for this view's bean class */ + readonly entityMetadata: EntityMetadata | null; + /** Current view state */ + protected state: ViewState; + + private readonly _eventHandlers = new Map>(); + + constructor(viewType: ViewType, descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + this.viewType = viewType; + this.descriptor = descriptor; + this.entityMetadata = entityMetadata; + this.state = { loading: false, error: null, initialized: false }; + } + + /** Initialize the view. Must be called after construction. */ + abstract initialize(): Promise; + + /** Validate the current view state. Returns true if valid. */ + abstract validate(): boolean; + + /** Get the primary value held by this view */ + getValue(): unknown { return undefined; } + + /** Set the primary value on this view */ + setValue(_value: unknown): void {} + + /** Get the data source for this view (e.g. list of entities) */ + getSource(): unknown { return undefined; } + + /** Set the data source for this view */ + setSource(_source: unknown): void {} + + /** Whether this view is in read-only mode */ + isReadOnly(): boolean { return false; } + + /** Set read-only mode */ + setReadOnly(_readOnly: boolean): void {} + + /** Register an event handler */ + on(event: string, handler: EventHandler): void { + if (!this._eventHandlers.has(event)) { + this._eventHandlers.set(event, new Set()); + } + this._eventHandlers.get(event)!.add(handler); + } + + /** Unregister an event handler */ + off(event: string, handler: EventHandler): void { + this._eventHandlers.get(event)?.delete(handler); + } + + /** Emit an event to all registered handlers */ + emit(event: string, payload?: unknown): void { + this._eventHandlers.get(event)?.forEach(h => h(payload)); + } + + /** Get current view state (read-only snapshot) */ + getState(): Readonly { return { ...this.state }; } +} diff --git a/platform/packages/ui-core/src/view/ViewType.ts b/platform/packages/ui-core/src/view/ViewType.ts new file mode 100644 index 00000000..b647fcff --- /dev/null +++ b/platform/packages/ui-core/src/view/ViewType.ts @@ -0,0 +1,33 @@ +// ViewType: open extension interface for view type identity + +/** + * Identity interface for a view type. + * Anyone can implement this interface to register new view types. + * This is intentionally NOT an enum so external modules can extend it. + * + * Example (custom view type): + *
{@code
+ * const KanbanViewType: ViewType = { name: 'kanban' };
+ * }
+ */ +export interface ViewType { + /** Unique name identifier for this view type (e.g. 'form', 'table', 'crud') */ + readonly name: string; +} + +/** + * Built-in view types shipped with ui-core. + * Plain objects satisfying ViewType — not enum values. + * Third-party modules define their own ViewType instances the same way. + */ +export const ViewTypes = { + Form: { name: 'form' }, + Table: { name: 'table' }, + Crud: { name: 'crud' }, + Tree: { name: 'tree' }, + Config: { name: 'config' }, + EntityPicker: { name: 'entitypicker' }, + EntityFilters: { name: 'entityfilters' }, + Export: { name: 'export' }, + Json: { name: 'json' }, +} as const satisfies Record; diff --git a/platform/packages/ui-core/src/viewer/ViewRendererRegistry.ts b/platform/packages/ui-core/src/viewer/ViewRendererRegistry.ts new file mode 100644 index 00000000..58f0636a --- /dev/null +++ b/platform/packages/ui-core/src/viewer/ViewRendererRegistry.ts @@ -0,0 +1,102 @@ +// ViewRendererRegistry: central registry mapping ViewType to ViewRenderer and View factories + +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import type { ViewType } from '../view/ViewType.js'; +import type { View } from '../view/View.js'; +import type { ViewRenderer } from '../renderer/ViewRenderer.js'; + +type ViewFactory = (descriptor: ViewDescriptor, metadata: EntityMetadata | null) => View; + +/** + * Central registry that maps ViewType to ViewRenderer and View factory functions. + * Used by Viewer to resolve the correct renderer and view for a given ViewType. + * Both ui-core built-ins and external modules register here. + * + * Example (Vue plugin registration): + *
{@code
+ * ViewRendererRegistry.register(ViewTypes.Form, new VueFormRenderer());
+ * ViewRendererRegistry.registerViewFactory(ViewTypes.Form, (d, m) => new VueFormView(d, m));
+ * }
+ */ +export class ViewRendererRegistry { + private static readonly _renderers = new Map>(); + private static readonly _factories = new Map(); + + /** + * Register a renderer for a ViewType. + * @param type - The ViewType to register for + * @param renderer - The renderer implementation + */ + static register( + type: ViewType, + renderer: ViewRenderer + ): void { + ViewRendererRegistry._renderers.set(type.name, renderer as ViewRenderer); + } + + /** + * Retrieve a registered renderer for a ViewType. + * @param type - The ViewType to look up + * @returns The registered renderer + * @throws Error if no renderer is registered for the ViewType + */ + static getRenderer(type: ViewType): ViewRenderer { + const renderer = ViewRendererRegistry._renderers.get(type.name); + if (!renderer) throw new Error(`No renderer registered for ViewType '${type.name}'`); + return renderer; + } + + /** + * Check if a renderer is registered for a ViewType. + * @param type - The ViewType to check + * @returns true if a renderer is registered + */ + static hasRenderer(type: ViewType): boolean { + return ViewRendererRegistry._renderers.has(type.name); + } + + /** + * Register a factory function for creating View instances for a ViewType. + * @param type - The ViewType to register for + * @param factory - Factory function creating the correct View subclass + */ + static registerViewFactory( + type: ViewType, + factory: ViewFactory + ): void { + ViewRendererRegistry._factories.set(type.name, factory); + } + + /** + * Create the correct View subclass for a ViewType. + * @param type - The ViewType to create a View for + * @param descriptor - The view descriptor + * @param metadata - The entity metadata + * @returns A new View instance for the given ViewType + * @throws Error if no factory is registered for the ViewType + */ + static createView( + type: ViewType, + descriptor: ViewDescriptor, + metadata: EntityMetadata | null + ): View { + const factory = ViewRendererRegistry._factories.get(type.name); + if (!factory) throw new Error(`No view factory registered for ViewType '${type.name}'`); + return factory(descriptor, metadata); + } + + /** + * Check if a view factory is registered for a ViewType. + * @param type - The ViewType to check + * @returns true if a factory is registered + */ + static hasViewFactory(type: ViewType): boolean { + return ViewRendererRegistry._factories.has(type.name); + } + + /** Clear all registered renderers and factories (useful for testing) */ + static clear(): void { + ViewRendererRegistry._renderers.clear(); + ViewRendererRegistry._factories.clear(); + } +} diff --git a/platform/packages/ui-core/src/viewer/Viewer.ts b/platform/packages/ui-core/src/viewer/Viewer.ts new file mode 100644 index 00000000..3b9eb249 --- /dev/null +++ b/platform/packages/ui-core/src/viewer/Viewer.ts @@ -0,0 +1,267 @@ +// Viewer: universal view host that resolves ViewType → View → output + +import type { ViewDescriptor, EntityMetadata, DynamiaClient } from '@dynamia-tools/sdk'; +import type { ViewType } from '../view/ViewType.js'; +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'; + +/** Configuration options for Viewer initialization */ +export interface ViewerConfig { + /** View type name or ViewType object */ + viewType?: string | ViewType | null; + /** Entity class name (e.g. 'com.example.Book') */ + beanClass?: string | null; + /** Pre-loaded view descriptor (skips fetch) */ + descriptor?: ViewDescriptor | null; + /** Descriptor ID to fetch from backend */ + descriptorId?: string | null; + /** Initial value for the view */ + value?: unknown; + /** Initial source/data for the view */ + source?: unknown; + /** Whether the view is in read-only mode */ + readOnly?: boolean; + /** Dynamia client instance for API calls */ + client?: DynamiaClient | null; +} + +/** + * Universal view host that resolves ViewType → View → rendered output. + * Primary abstraction for consumers — mirrors the ZK tools.dynamia.zk.viewers.ui.Viewer. + * + * Example: + *
{@code
+ * const viewer = new Viewer({ viewType: 'form', beanClass: 'com.example.Book', client });
+ * await viewer.initialize();
+ * viewer.setValue(book);
+ * }
+ */ +export class Viewer { + viewType: string | ViewType | null; + beanClass: string | null; + descriptor: ViewDescriptor | null; + descriptorId: string | null; + value: unknown; + source: unknown; + readOnly: boolean; + client: DynamiaClient | null; + + private _view: View | null = null; + private _resolvedDescriptor: ViewDescriptor | null = null; + private _resolvedViewType: ViewType | null = null; + private _actions: ActionMetadata[] = []; + private _pendingEvents: Array<{ event: string; handler: EventHandler }> = []; + private _initialized = false; + + constructor(config: ViewerConfig = {}) { + this.viewType = config.viewType ?? null; + this.beanClass = config.beanClass ?? null; + this.descriptor = config.descriptor ?? null; + this.descriptorId = config.descriptorId ?? null; + this.value = config.value; + this.source = config.source; + this.readOnly = config.readOnly ?? false; + this.client = config.client ?? null; + } + + /** The resolved View instance (available after initialize()) */ + get view(): View | null { return this._view; } + + /** The resolved ViewDescriptor (available after initialize()) */ + get resolvedDescriptor(): ViewDescriptor | null { return this._resolvedDescriptor; } + + /** The resolved ViewType (available after initialize()) */ + get resolvedViewType(): ViewType | null { return this._resolvedViewType; } + + /** + * Initialize the viewer: resolve descriptor, create view, apply config. + * Must be called before accessing view or rendering. + */ + async initialize(): Promise { + // 1. Resolve descriptor + await this._resolveDescriptor(); + + // 2. Resolve view type + this._resolvedViewType = this._resolveViewType(); + if (!this._resolvedViewType) throw new Error('Cannot resolve ViewType — set viewType, descriptor, or descriptorId'); + + // 3. Create view + if (ViewRendererRegistry.hasViewFactory(this._resolvedViewType)) { + this._view = ViewRendererRegistry.createView( + this._resolvedViewType, + this._resolvedDescriptor!, + null + ); + } else { + // Fall back to basic view creation based on view type + this._view = this._createFallbackView(this._resolvedViewType, this._resolvedDescriptor!); + } + + if (!this._view) throw new Error(`Cannot create view for ViewType '${this._resolvedViewType.name}'`); + + // 4. Apply pending event listeners + for (const { event, handler } of this._pendingEvents) { + this._view.on(event, handler); + } + this._pendingEvents = []; + + // 5. Apply value, source, readOnly + if (this.value !== undefined) this._view.setValue(this.value); + if (this.source !== undefined) this._view.setSource(this.source); + this._view.setReadOnly(this.readOnly); + + // 6. Initialize the view + await this._view.initialize(); + + // 7. Load actions from entity metadata if available + if (this.client && this.beanClass) { + try { + const entities = await this.client.metadata.getEntities(); + const entity = entities.entities.find(e => e.className === this.beanClass); + if (entity) this._actions = entity.actions; + } catch { + // Ignore — actions are optional + } + } + + this._initialized = true; + } + + /** Clean up the viewer and its view */ + destroy(): void { + this._view = null; + this._resolvedDescriptor = null; + this._resolvedViewType = null; + this._actions = []; + this._pendingEvents = []; + this._initialized = false; + } + + /** Get the primary value from the view */ + getValue(): unknown { return this._view?.getValue(); } + + /** Set the primary value on the view */ + setValue(value: unknown): void { + this.value = value; + if (this._view) this._view.setValue(value); + } + + /** Get selected item (for dataset views like table) */ + getSelected(): unknown { + return (this._view as unknown as { getSelectedRow?: () => unknown })?.getSelectedRow?.(); + } + + /** Set selected item */ + setSelected(value: unknown): void { + (this._view as unknown as { selectRow?: (row: unknown) => void })?.selectRow?.(value); + } + + /** Add an action to the viewer */ + addAction(action: ActionMetadata): void { this._actions.push(action); } + + /** Get all resolved actions */ + getActions(): ActionMetadata[] { return this._actions; } + + /** + * Register an event listener. + * If called before initialize(), the listener is buffered and applied after. + */ + on(event: string, handler: EventHandler): void { + if (this._view) { + this._view.on(event, handler); + } else { + this._pendingEvents.push({ event, handler }); + } + } + + /** Remove an event listener */ + off(event: string, handler: EventHandler): void { + this._view?.off(event, handler); + } + + /** Set read-only mode */ + setReadonly(readOnly: boolean): void { + this.readOnly = readOnly; + this._view?.setReadOnly(readOnly); + } + + /** Whether the viewer is in read-only mode */ + isReadonly(): boolean { return this.readOnly; } + + /** Whether the viewer has been initialized */ + isInitialized(): boolean { return this._initialized; } + + private async _resolveDescriptor(): Promise { + if (this.descriptor) { + // Use pre-loaded descriptor directly + this._resolvedDescriptor = this.descriptor; + return; + } + if (!this.client) { + // No client — cannot fetch; descriptor must be provided + if (!this.descriptor) throw new Error('Either provide a descriptor or a DynamiaClient to fetch it'); + return; + } + if (this.descriptorId) { + // Fetch by ID + const meta = await this.client.metadata.getEntities(); + for (const entity of meta.entities) { + for (const d of entity.descriptors) { + if (d.descriptor.id === this.descriptorId) { + this._resolvedDescriptor = d.descriptor; + if (!this.beanClass) this.beanClass = entity.className; + return; + } + } + } + throw new Error(`Descriptor with id '${this.descriptorId}' not found`); + } + if (this.beanClass && this.viewType) { + // Fetch by beanClass + viewType + const typeName = typeof this.viewType === 'string' ? this.viewType : this.viewType.name; + const meta = await this.client.metadata.getEntities(); + const entity = meta.entities.find(e => e.className === this.beanClass); + if (entity) { + const found = entity.descriptors.find(d => d.view === typeName || d.descriptor.viewTypeName === typeName); + if (found) { + this._resolvedDescriptor = found.descriptor; + return; + } + } + } + // If we get here and still no descriptor, create a minimal one + if (!this._resolvedDescriptor) { + this._resolvedDescriptor = { + id: `${this.beanClass ?? 'unknown'}-${typeof this.viewType === 'string' ? this.viewType : this.viewType?.name ?? 'form'}`, + beanClass: this.beanClass ?? '', + viewTypeName: typeof this.viewType === 'string' ? this.viewType : this.viewType?.name ?? 'form', + fields: [], + params: {}, + }; + } + } + + private _resolveViewType(): ViewType | null { + if (this.viewType) { + if (typeof this.viewType === 'string') { + const found = Object.values(ViewTypes).find(vt => vt.name === this.viewType); + return found ?? { name: this.viewType as string }; + } + return this.viewType; + } + if (this._resolvedDescriptor) { + const typeName = this._resolvedDescriptor.viewTypeName; + const found = Object.values(ViewTypes).find(vt => vt.name === typeName); + return found ?? { name: typeName }; + } + return null; + } + + private _createFallbackView(_type: ViewType, _descriptor: ViewDescriptor): View | null { + // Dynamically import concrete view classes to avoid circular deps at module level + // Return null — callers should always register a factory + return null; + } +} diff --git a/platform/packages/ui-core/tsconfig.build.json b/platform/packages/ui-core/tsconfig.build.json new file mode 100644 index 00000000..79d28936 --- /dev/null +++ b/platform/packages/ui-core/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "declaration": true, + "declarationMap": true + }, + "include": ["src"] +} diff --git a/platform/packages/ui-core/tsconfig.json b/platform/packages/ui-core/tsconfig.json new file mode 100644 index 00000000..e24c8062 --- /dev/null +++ b/platform/packages/ui-core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": {"noEmit": true}, + "include": ["src", "test"] +} diff --git a/platform/packages/ui-core/vite.config.ts b/platform/packages/ui-core/vite.config.ts new file mode 100644 index 00000000..b45f9e86 --- /dev/null +++ b/platform/packages/ui-core/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [ + dts({ + include: ['src'], + outDir: 'dist', + insertTypesEntry: true, + tsconfigPath: './tsconfig.build.json', + }), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'DynamiaUiCore', + formats: ['es', 'cjs'], + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), + }, + rollupOptions: { + external: ['@dynamia-tools/sdk'], + }, + sourcemap: true, + minify: false, + }, +}); From db77ac410f3f02564088824f4be98d9a484353f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:41:36 +0000 Subject: [PATCH 03/61] fix: address code review comments in ui-core package - Replace em dash with standard hyphen in Viewer error message - Remove redundant conditional in _resolveDescriptor (descriptor already checked at start of method) - Clarify FieldComponent comment: external modules define their own constants rather than extending the const object - Clarify ViewType JSDoc: external modules create their own ViewType objects and register them independently Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- platform/packages/ui-core/src/types/field.ts | 3 ++- platform/packages/ui-core/src/view/ViewType.ts | 3 ++- platform/packages/ui-core/src/viewer/Viewer.ts | 7 +++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/platform/packages/ui-core/src/types/field.ts b/platform/packages/ui-core/src/types/field.ts index e980ee8a..a62f476c 100644 --- a/platform/packages/ui-core/src/types/field.ts +++ b/platform/packages/ui-core/src/types/field.ts @@ -5,7 +5,8 @@ import type { ViewField } from '@dynamia-tools/sdk'; /** * Maps descriptor component strings to known field component identifiers. * Names match ZK component names exactly for vocabulary consistency. - * This is a const object, not an enum, so external modules can extend it. + * This is a const object, not an enum. External modules should define their + * own component identifier constants following the same pattern. */ export const FieldComponent = { Textbox: 'textbox', diff --git a/platform/packages/ui-core/src/view/ViewType.ts b/platform/packages/ui-core/src/view/ViewType.ts index b647fcff..f2d1d05b 100644 --- a/platform/packages/ui-core/src/view/ViewType.ts +++ b/platform/packages/ui-core/src/view/ViewType.ts @@ -3,7 +3,8 @@ /** * Identity interface for a view type. * Anyone can implement this interface to register new view types. - * This is intentionally NOT an enum so external modules can extend it. + * This is intentionally NOT an enum — external modules define their own + * ViewType objects and register them with ViewRendererRegistry independently. * * Example (custom view type): *
{@code
diff --git a/platform/packages/ui-core/src/viewer/Viewer.ts b/platform/packages/ui-core/src/viewer/Viewer.ts
index 3b9eb249..a68666d7 100644
--- a/platform/packages/ui-core/src/viewer/Viewer.ts
+++ b/platform/packages/ui-core/src/viewer/Viewer.ts
@@ -85,7 +85,7 @@ export class Viewer {
 
     // 2. Resolve view type
     this._resolvedViewType = this._resolveViewType();
-    if (!this._resolvedViewType) throw new Error('Cannot resolve ViewType — set viewType, descriptor, or descriptorId');
+    if (!this._resolvedViewType) throw new Error('Cannot resolve ViewType - set viewType, descriptor, or descriptorId');
 
     // 3. Create view
     if (ViewRendererRegistry.hasViewFactory(this._resolvedViewType)) {
@@ -200,9 +200,8 @@ export class Viewer {
       return;
     }
     if (!this.client) {
-      // No client — cannot fetch; descriptor must be provided
-      if (!this.descriptor) throw new Error('Either provide a descriptor or a DynamiaClient to fetch it');
-      return;
+      // 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 ID

From 9099cb76ad7756761497231b71996da16956d09c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Mar 2026 17:01:16 +0000
Subject: [PATCH 04/61] feat: add @dynamia-tools/vue TypeScript/Vue 3 adapter
 package

- Vue-reactive view classes (VueFormView, VueTableView, VueCrudView,
  VueTreeView, VueConfigView, VueEntityPickerView, VueViewer, VueView)
  extending ui-core views with Vue refs and computed properties
- Vue renderer implementations (VueFormRenderer, VueTableRenderer,
  VueCrudRenderer, VueFieldRenderer)
- Composables: useViewer, useView, useForm, useTable, useCrud,
  useEntityPicker, useNavigation
- Vue components: Viewer, Form, Table, Crud, Field, Actions,
  NavMenu, NavBreadcrumb
- Field components: Textbox, Intbox, Spinner, Combobox, Datebox,
  Checkbox, EntityPicker, EntityRefPicker, EntityRefLabel, CoolLabel, Link
- DynamiaVue plugin that registers all renderers, factories and
  global components
- Added 'vue' to pnpm workspace packages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 platform/packages/pnpm-lock.yaml              | 207 ++++++++++++++++++
 platform/packages/pnpm-workspace.yaml         |   1 +
 platform/packages/vue/package.json            |  43 ++++
 .../packages/vue/src/components/Actions.vue   |  33 +++
 platform/packages/vue/src/components/Crud.vue |  62 ++++++
 .../packages/vue/src/components/Field.vue     |  69 ++++++
 platform/packages/vue/src/components/Form.vue |  80 +++++++
 .../vue/src/components/NavBreadcrumb.vue      |  31 +++
 .../packages/vue/src/components/NavMenu.vue   |  52 +++++
 .../packages/vue/src/components/Table.vue     |  77 +++++++
 .../packages/vue/src/components/Viewer.vue    |  95 ++++++++
 .../vue/src/components/fields/Checkbox.vue    |  32 +++
 .../vue/src/components/fields/Combobox.vue    |  51 +++++
 .../vue/src/components/fields/CoolLabel.vue   |  40 ++++
 .../vue/src/components/fields/Datebox.vue     |  43 ++++
 .../src/components/fields/EntityPicker.vue    |  74 +++++++
 .../src/components/fields/EntityRefLabel.vue  |  25 +++
 .../src/components/fields/EntityRefPicker.vue |  64 ++++++
 .../vue/src/components/fields/Intbox.vue      |  34 +++
 .../vue/src/components/fields/Link.vue        |  34 +++
 .../vue/src/components/fields/Spinner.vue     |  34 +++
 .../vue/src/components/fields/Textbox.vue     |  32 +++
 .../packages/vue/src/composables/useCrud.ts   |  92 ++++++++
 .../vue/src/composables/useEntityPicker.ts    |  53 +++++
 .../packages/vue/src/composables/useForm.ts   |  64 ++++++
 .../vue/src/composables/useNavigation.ts      |  88 ++++++++
 .../packages/vue/src/composables/useTable.ts  |  78 +++++++
 .../packages/vue/src/composables/useView.ts   |  30 +++
 .../packages/vue/src/composables/useViewer.ts |  56 +++++
 platform/packages/vue/src/index.ts            |  43 ++++
 platform/packages/vue/src/plugin.ts           |  76 +++++++
 .../vue/src/renderers/VueCrudRenderer.ts      |  18 ++
 .../vue/src/renderers/VueFieldRenderer.ts     |  20 ++
 .../vue/src/renderers/VueFormRenderer.ts      |  19 ++
 .../vue/src/renderers/VueTableRenderer.ts     |  18 ++
 .../packages/vue/src/views/VueConfigView.ts   |  38 ++++
 .../packages/vue/src/views/VueCrudView.ts     |  88 ++++++++
 .../vue/src/views/VueEntityPickerView.ts      |  47 ++++
 .../packages/vue/src/views/VueFormView.ts     |  82 +++++++
 .../packages/vue/src/views/VueTableView.ts    |  88 ++++++++
 .../packages/vue/src/views/VueTreeView.ts     |  47 ++++
 platform/packages/vue/src/views/VueView.ts    |  39 ++++
 platform/packages/vue/src/views/VueViewer.ts  |  54 +++++
 platform/packages/vue/tsconfig.build.json     |  12 +
 platform/packages/vue/tsconfig.json           |  10 +
 platform/packages/vue/vite.config.ts          |  36 +++
 46 files changed, 2409 insertions(+)
 create mode 100644 platform/packages/vue/package.json
 create mode 100644 platform/packages/vue/src/components/Actions.vue
 create mode 100644 platform/packages/vue/src/components/Crud.vue
 create mode 100644 platform/packages/vue/src/components/Field.vue
 create mode 100644 platform/packages/vue/src/components/Form.vue
 create mode 100644 platform/packages/vue/src/components/NavBreadcrumb.vue
 create mode 100644 platform/packages/vue/src/components/NavMenu.vue
 create mode 100644 platform/packages/vue/src/components/Table.vue
 create mode 100644 platform/packages/vue/src/components/Viewer.vue
 create mode 100644 platform/packages/vue/src/components/fields/Checkbox.vue
 create mode 100644 platform/packages/vue/src/components/fields/Combobox.vue
 create mode 100644 platform/packages/vue/src/components/fields/CoolLabel.vue
 create mode 100644 platform/packages/vue/src/components/fields/Datebox.vue
 create mode 100644 platform/packages/vue/src/components/fields/EntityPicker.vue
 create mode 100644 platform/packages/vue/src/components/fields/EntityRefLabel.vue
 create mode 100644 platform/packages/vue/src/components/fields/EntityRefPicker.vue
 create mode 100644 platform/packages/vue/src/components/fields/Intbox.vue
 create mode 100644 platform/packages/vue/src/components/fields/Link.vue
 create mode 100644 platform/packages/vue/src/components/fields/Spinner.vue
 create mode 100644 platform/packages/vue/src/components/fields/Textbox.vue
 create mode 100644 platform/packages/vue/src/composables/useCrud.ts
 create mode 100644 platform/packages/vue/src/composables/useEntityPicker.ts
 create mode 100644 platform/packages/vue/src/composables/useForm.ts
 create mode 100644 platform/packages/vue/src/composables/useNavigation.ts
 create mode 100644 platform/packages/vue/src/composables/useTable.ts
 create mode 100644 platform/packages/vue/src/composables/useView.ts
 create mode 100644 platform/packages/vue/src/composables/useViewer.ts
 create mode 100644 platform/packages/vue/src/index.ts
 create mode 100644 platform/packages/vue/src/plugin.ts
 create mode 100644 platform/packages/vue/src/renderers/VueCrudRenderer.ts
 create mode 100644 platform/packages/vue/src/renderers/VueFieldRenderer.ts
 create mode 100644 platform/packages/vue/src/renderers/VueFormRenderer.ts
 create mode 100644 platform/packages/vue/src/renderers/VueTableRenderer.ts
 create mode 100644 platform/packages/vue/src/views/VueConfigView.ts
 create mode 100644 platform/packages/vue/src/views/VueCrudView.ts
 create mode 100644 platform/packages/vue/src/views/VueEntityPickerView.ts
 create mode 100644 platform/packages/vue/src/views/VueFormView.ts
 create mode 100644 platform/packages/vue/src/views/VueTableView.ts
 create mode 100644 platform/packages/vue/src/views/VueTreeView.ts
 create mode 100644 platform/packages/vue/src/views/VueView.ts
 create mode 100644 platform/packages/vue/src/views/VueViewer.ts
 create mode 100644 platform/packages/vue/tsconfig.build.json
 create mode 100644 platform/packages/vue/tsconfig.json
 create mode 100644 platform/packages/vue/vite.config.ts

diff --git a/platform/packages/pnpm-lock.yaml b/platform/packages/pnpm-lock.yaml
index b61c3d7d..d34f7b2d 100644
--- a/platform/packages/pnpm-lock.yaml
+++ b/platform/packages/pnpm-lock.yaml
@@ -75,6 +75,36 @@ importers:
         specifier: ^3.0.0
         version: 3.2.4(@types/node@22.19.15)
 
+  vue:
+    devDependencies:
+      '@dynamia-tools/sdk':
+        specifier: workspace:*
+        version: link:../sdk
+      '@dynamia-tools/ui-core':
+        specifier: workspace:*
+        version: link:../ui-core
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.19.15
+      '@vitejs/plugin-vue':
+        specifier: ^5.0.0
+        version: 5.2.4(vite@6.4.1(@types/node@22.19.15))(vue@3.5.30(typescript@5.9.3))
+      typescript:
+        specifier: ^5.7.0
+        version: 5.9.3
+      vite:
+        specifier: ^6.2.0
+        version: 6.4.1(@types/node@22.19.15)
+      vite-plugin-dts:
+        specifier: ^4.5.0
+        version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15))
+      vue:
+        specifier: ^3.4.0
+        version: 3.5.30(typescript@5.9.3)
+      vue-tsc:
+        specifier: ^2.0.0
+        version: 2.2.12(typescript@5.9.3)
+
 packages:
 
   '@ampproject/remapping@2.3.0':
@@ -545,6 +575,13 @@ packages:
   '@types/node@22.19.15':
     resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
 
+  '@vitejs/plugin-vue@5.2.4':
+    resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    peerDependencies:
+      vite: ^5.0.0 || ^6.0.0
+      vue: ^3.2.25
+
   '@vitest/coverage-v8@3.2.4':
     resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
     peerDependencies:
@@ -583,21 +620,42 @@ packages:
   '@vitest/utils@3.2.4':
     resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
 
+  '@volar/language-core@2.4.15':
+    resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
+
   '@volar/language-core@2.4.28':
     resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
 
+  '@volar/source-map@2.4.15':
+    resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
+
   '@volar/source-map@2.4.28':
     resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
 
+  '@volar/typescript@2.4.15':
+    resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
+
   '@volar/typescript@2.4.28':
     resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
 
   '@vue/compiler-core@3.5.29':
     resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==}
 
+  '@vue/compiler-core@3.5.30':
+    resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
+
   '@vue/compiler-dom@3.5.29':
     resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==}
 
+  '@vue/compiler-dom@3.5.30':
+    resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
+
+  '@vue/compiler-sfc@3.5.30':
+    resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
+
+  '@vue/compiler-ssr@3.5.30':
+    resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
+
   '@vue/compiler-vue2@2.7.16':
     resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
 
@@ -609,9 +667,34 @@ packages:
       typescript:
         optional: true
 
+  '@vue/language-core@2.2.12':
+    resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@vue/reactivity@3.5.30':
+    resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
+
+  '@vue/runtime-core@3.5.30':
+    resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
+
+  '@vue/runtime-dom@3.5.30':
+    resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
+
+  '@vue/server-renderer@3.5.30':
+    resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
+    peerDependencies:
+      vue: 3.5.30
+
   '@vue/shared@3.5.29':
     resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==}
 
+  '@vue/shared@3.5.30':
+    resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
+
   acorn-jsx@5.3.2:
     resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
     peerDependencies:
@@ -647,6 +730,9 @@ packages:
   alien-signals@0.4.14:
     resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==}
 
+  alien-signals@1.0.13:
+    resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
+
   ansi-regex@5.0.1:
     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
     engines: {node: '>=8'}
@@ -736,6 +822,9 @@ packages:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  csstype@3.2.3:
+    resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
   de-indent@1.0.2:
     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
 
@@ -1398,6 +1487,20 @@ packages:
   vscode-uri@3.1.0:
     resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
 
+  vue-tsc@2.2.12:
+    resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
+    hasBin: true
+    peerDependencies:
+      typescript: '>=5.0.0'
+
+  vue@3.5.30:
+    resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
   which@2.0.2:
     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
     engines: {node: '>= 8'}
@@ -1787,6 +1890,11 @@ snapshots:
     dependencies:
       undici-types: 6.21.0
 
+  '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@22.19.15))(vue@3.5.30(typescript@5.9.3))':
+    dependencies:
+      vite: 6.4.1(@types/node@22.19.15)
+      vue: 3.5.30(typescript@5.9.3)
+
   '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.15))':
     dependencies:
       '@ampproject/remapping': 2.3.0
@@ -1848,12 +1956,24 @@ snapshots:
       loupe: 3.2.1
       tinyrainbow: 2.0.0
 
+  '@volar/language-core@2.4.15':
+    dependencies:
+      '@volar/source-map': 2.4.15
+
   '@volar/language-core@2.4.28':
     dependencies:
       '@volar/source-map': 2.4.28
 
+  '@volar/source-map@2.4.15': {}
+
   '@volar/source-map@2.4.28': {}
 
+  '@volar/typescript@2.4.15':
+    dependencies:
+      '@volar/language-core': 2.4.15
+      path-browserify: 1.0.1
+      vscode-uri: 3.1.0
+
   '@volar/typescript@2.4.28':
     dependencies:
       '@volar/language-core': 2.4.28
@@ -1868,11 +1988,41 @@ snapshots:
       estree-walker: 2.0.2
       source-map-js: 1.2.1
 
+  '@vue/compiler-core@3.5.30':
+    dependencies:
+      '@babel/parser': 7.29.0
+      '@vue/shared': 3.5.30
+      entities: 7.0.1
+      estree-walker: 2.0.2
+      source-map-js: 1.2.1
+
   '@vue/compiler-dom@3.5.29':
     dependencies:
       '@vue/compiler-core': 3.5.29
       '@vue/shared': 3.5.29
 
+  '@vue/compiler-dom@3.5.30':
+    dependencies:
+      '@vue/compiler-core': 3.5.30
+      '@vue/shared': 3.5.30
+
+  '@vue/compiler-sfc@3.5.30':
+    dependencies:
+      '@babel/parser': 7.29.0
+      '@vue/compiler-core': 3.5.30
+      '@vue/compiler-dom': 3.5.30
+      '@vue/compiler-ssr': 3.5.30
+      '@vue/shared': 3.5.30
+      estree-walker: 2.0.2
+      magic-string: 0.30.21
+      postcss: 8.5.8
+      source-map-js: 1.2.1
+
+  '@vue/compiler-ssr@3.5.30':
+    dependencies:
+      '@vue/compiler-dom': 3.5.30
+      '@vue/shared': 3.5.30
+
   '@vue/compiler-vue2@2.7.16':
     dependencies:
       de-indent: 1.0.2
@@ -1891,8 +2041,45 @@ snapshots:
     optionalDependencies:
       typescript: 5.9.3
 
+  '@vue/language-core@2.2.12(typescript@5.9.3)':
+    dependencies:
+      '@volar/language-core': 2.4.15
+      '@vue/compiler-dom': 3.5.29
+      '@vue/compiler-vue2': 2.7.16
+      '@vue/shared': 3.5.29
+      alien-signals: 1.0.13
+      minimatch: 9.0.9
+      muggle-string: 0.4.1
+      path-browserify: 1.0.1
+    optionalDependencies:
+      typescript: 5.9.3
+
+  '@vue/reactivity@3.5.30':
+    dependencies:
+      '@vue/shared': 3.5.30
+
+  '@vue/runtime-core@3.5.30':
+    dependencies:
+      '@vue/reactivity': 3.5.30
+      '@vue/shared': 3.5.30
+
+  '@vue/runtime-dom@3.5.30':
+    dependencies:
+      '@vue/reactivity': 3.5.30
+      '@vue/runtime-core': 3.5.30
+      '@vue/shared': 3.5.30
+      csstype: 3.2.3
+
+  '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))':
+    dependencies:
+      '@vue/compiler-ssr': 3.5.30
+      '@vue/shared': 3.5.30
+      vue: 3.5.30(typescript@5.9.3)
+
   '@vue/shared@3.5.29': {}
 
+  '@vue/shared@3.5.30': {}
+
   acorn-jsx@5.3.2(acorn@8.16.0):
     dependencies:
       acorn: 8.16.0
@@ -1923,6 +2110,8 @@ snapshots:
 
   alien-signals@0.4.14: {}
 
+  alien-signals@1.0.13: {}
+
   ansi-regex@5.0.1: {}
 
   ansi-regex@6.2.2: {}
@@ -2003,6 +2192,8 @@ snapshots:
       shebang-command: 2.0.0
       which: 2.0.2
 
+  csstype@3.2.3: {}
+
   de-indent@1.0.2: {}
 
   debug@4.4.3:
@@ -2668,6 +2859,22 @@ snapshots:
 
   vscode-uri@3.1.0: {}
 
+  vue-tsc@2.2.12(typescript@5.9.3):
+    dependencies:
+      '@volar/typescript': 2.4.15
+      '@vue/language-core': 2.2.12(typescript@5.9.3)
+      typescript: 5.9.3
+
+  vue@3.5.30(typescript@5.9.3):
+    dependencies:
+      '@vue/compiler-dom': 3.5.30
+      '@vue/compiler-sfc': 3.5.30
+      '@vue/runtime-dom': 3.5.30
+      '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3))
+      '@vue/shared': 3.5.30
+    optionalDependencies:
+      typescript: 5.9.3
+
   which@2.0.2:
     dependencies:
       isexe: 2.0.0
diff --git a/platform/packages/pnpm-workspace.yaml b/platform/packages/pnpm-workspace.yaml
index 9ed40f27..a10c35a5 100644
--- a/platform/packages/pnpm-workspace.yaml
+++ b/platform/packages/pnpm-workspace.yaml
@@ -1,5 +1,6 @@
 packages:
   - 'sdk'
   - 'ui-core'
+  - 'vue'
 allowBuilds:
   esbuild: true
diff --git a/platform/packages/vue/package.json b/platform/packages/vue/package.json
new file mode 100644
index 00000000..63d6becf
--- /dev/null
+++ b/platform/packages/vue/package.json
@@ -0,0 +1,43 @@
+{
+  "name": "@dynamia-tools/vue",
+  "version": "26.3.1",
+  "description": "Vue 3 adapter for Dynamia Platform UI",
+  "keywords": ["dynamia", "vue", "viewer", "form", "table", "crud", "typescript"],
+  "homepage": "https://dynamia.tools",
+  "repository": {"type": "git", "url": "https://github.com/dynamiatools/framework.git", "directory": "platform/packages/vue"},
+  "license": "Apache-2.0",
+  "author": "Dynamia Soluciones IT SAS",
+  "type": "module",
+  "main": "./dist/index.cjs",
+  "module": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "import": {"types": "./dist/index.d.ts", "default": "./dist/index.js"},
+      "require": {"types": "./dist/index.d.cts", "default": "./dist/index.cjs"}
+    }
+  },
+  "files": ["dist", "README.md", "LICENSE"],
+  "scripts": {
+    "build": "vite build",
+    "typecheck": "vue-tsc --noEmit",
+    "clean": "rm -rf dist"
+  },
+  "peerDependencies": {
+    "@dynamia-tools/sdk": ">=26.0.0",
+    "@dynamia-tools/ui-core": ">=26.0.0",
+    "vue": "^3.4.0"
+  },
+  "devDependencies": {
+    "@dynamia-tools/sdk": "workspace:*",
+    "@dynamia-tools/ui-core": "workspace:*",
+    "@types/node": "^22.0.0",
+    "@vitejs/plugin-vue": "^5.0.0",
+    "typescript": "^5.7.0",
+    "vite": "^6.2.0",
+    "vite-plugin-dts": "^4.5.0",
+    "vue": "^3.4.0",
+    "vue-tsc": "^2.0.0"
+  },
+  "publishConfig": {"access": "public", "registry": "https://registry.npmjs.org/"}
+}
diff --git a/platform/packages/vue/src/components/Actions.vue b/platform/packages/vue/src/components/Actions.vue
new file mode 100644
index 00000000..8c93292e
--- /dev/null
+++ b/platform/packages/vue/src/components/Actions.vue
@@ -0,0 +1,33 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/Crud.vue b/platform/packages/vue/src/components/Crud.vue
new file mode 100644
index 00000000..df0e6dd7
--- /dev/null
+++ b/platform/packages/vue/src/components/Crud.vue
@@ -0,0 +1,62 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/Field.vue b/platform/packages/vue/src/components/Field.vue
new file mode 100644
index 00000000..0a481572
--- /dev/null
+++ b/platform/packages/vue/src/components/Field.vue
@@ -0,0 +1,69 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/Form.vue b/platform/packages/vue/src/components/Form.vue
new file mode 100644
index 00000000..1b2b009c
--- /dev/null
+++ b/platform/packages/vue/src/components/Form.vue
@@ -0,0 +1,80 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/NavBreadcrumb.vue b/platform/packages/vue/src/components/NavBreadcrumb.vue
new file mode 100644
index 00000000..462363b9
--- /dev/null
+++ b/platform/packages/vue/src/components/NavBreadcrumb.vue
@@ -0,0 +1,31 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/NavMenu.vue b/platform/packages/vue/src/components/NavMenu.vue
new file mode 100644
index 00000000..5ef1dff9
--- /dev/null
+++ b/platform/packages/vue/src/components/NavMenu.vue
@@ -0,0 +1,52 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/Table.vue b/platform/packages/vue/src/components/Table.vue
new file mode 100644
index 00000000..e9036868
--- /dev/null
+++ b/platform/packages/vue/src/components/Table.vue
@@ -0,0 +1,77 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/Viewer.vue b/platform/packages/vue/src/components/Viewer.vue
new file mode 100644
index 00000000..6d5d3782
--- /dev/null
+++ b/platform/packages/vue/src/components/Viewer.vue
@@ -0,0 +1,95 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/Checkbox.vue b/platform/packages/vue/src/components/fields/Checkbox.vue
new file mode 100644
index 00000000..771e808e
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/Checkbox.vue
@@ -0,0 +1,32 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/Combobox.vue b/platform/packages/vue/src/components/fields/Combobox.vue
new file mode 100644
index 00000000..9b780509
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/Combobox.vue
@@ -0,0 +1,51 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/CoolLabel.vue b/platform/packages/vue/src/components/fields/CoolLabel.vue
new file mode 100644
index 00000000..676ff147
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/CoolLabel.vue
@@ -0,0 +1,40 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/Datebox.vue b/platform/packages/vue/src/components/fields/Datebox.vue
new file mode 100644
index 00000000..1f84b626
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/Datebox.vue
@@ -0,0 +1,43 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/EntityPicker.vue b/platform/packages/vue/src/components/fields/EntityPicker.vue
new file mode 100644
index 00000000..775b750a
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/EntityPicker.vue
@@ -0,0 +1,74 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/EntityRefLabel.vue b/platform/packages/vue/src/components/fields/EntityRefLabel.vue
new file mode 100644
index 00000000..76ede9b5
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/EntityRefLabel.vue
@@ -0,0 +1,25 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/EntityRefPicker.vue b/platform/packages/vue/src/components/fields/EntityRefPicker.vue
new file mode 100644
index 00000000..bd55c00f
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/EntityRefPicker.vue
@@ -0,0 +1,64 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/Intbox.vue b/platform/packages/vue/src/components/fields/Intbox.vue
new file mode 100644
index 00000000..59ee136b
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/Intbox.vue
@@ -0,0 +1,34 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/Link.vue b/platform/packages/vue/src/components/fields/Link.vue
new file mode 100644
index 00000000..7a81eeaf
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/Link.vue
@@ -0,0 +1,34 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/Spinner.vue b/platform/packages/vue/src/components/fields/Spinner.vue
new file mode 100644
index 00000000..ea93bc21
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/Spinner.vue
@@ -0,0 +1,34 @@
+
+
+
+
diff --git a/platform/packages/vue/src/components/fields/Textbox.vue b/platform/packages/vue/src/components/fields/Textbox.vue
new file mode 100644
index 00000000..48088409
--- /dev/null
+++ b/platform/packages/vue/src/components/fields/Textbox.vue
@@ -0,0 +1,32 @@
+
+
+
+
diff --git a/platform/packages/vue/src/composables/useCrud.ts b/platform/packages/vue/src/composables/useCrud.ts
new file mode 100644
index 00000000..c42b8e01
--- /dev/null
+++ b/platform/packages/vue/src/composables/useCrud.ts
@@ -0,0 +1,92 @@
+// useCrud: composable for creating and managing a VueCrudView
+
+import { onMounted } from 'vue';
+import type { ViewDescriptor, CrudPageable, EntityMetadata } from '@dynamia-tools/sdk';
+import { VueCrudView } from '../views/VueCrudView.js';
+
+/** Options for useCrud composable */
+export interface UseCrudOptions {
+  /** Pre-loaded view descriptor */
+  descriptor: ViewDescriptor;
+  /** Entity metadata */
+  entityMetadata?: EntityMetadata | null;
+  /** Data loader function for the table */
+  loader?: (params: Record) => Promise<{ rows: unknown[]; pagination: CrudPageable | null }>;
+  /** Save handler called on form submit */
+  onSave?: (data: unknown, mode: 'create' | 'edit') => Promise;
+  /** Delete handler */
+  onDelete?: (entity: unknown) => Promise;
+}
+
+/**
+ * Composable for creating and managing a VueCrudView.
+ * Provides direct access to reactive CRUD state.
+ *
+ * Example:
+ * 
{@code
+ * const { view, mode, form, table, startCreate, startEdit, cancelEdit, save, remove } = useCrud({
+ *   descriptor,
+ *   loader: async (params) => { ... },
+ *   onSave: async (data, mode) => { ... },
+ * });
+ * }
+ * + * @param options - UseCrudOptions with descriptor and handlers + * @returns Object with VueCrudView and reactive state + */ +export function useCrud(options: UseCrudOptions) { + const view = new VueCrudView(options.descriptor, options.entityMetadata ?? null); + + if (options.loader) { + view.tableView.setLoader(options.loader); + } + + if (options.onSave) { + const handler = options.onSave; + view.on('save', (payload) => { + const { mode, data } = payload as { mode: 'create' | 'edit'; data: unknown }; + handler(data, mode).catch(console.error); + }); + } + + if (options.onDelete) { + const handler = options.onDelete; + view.on('delete', (entity) => { + handler(entity).catch(console.error); + }); + } + + onMounted(async () => { + await view.initialize(); + await view.tableView.load(); + }); + + return { + /** The VueCrudView instance */ + view, + /** Reactive CRUD mode */ + mode: view.mode, + /** The VueFormView */ + form: view.formView, + /** The VueTableView */ + table: view.tableView, + /** Reactive show-form computed */ + showForm: view.showForm, + /** Reactive show-table computed */ + showTable: view.showTable, + /** Reactive loading state */ + loading: view.isLoading, + /** Reactive error message */ + error: view.errorMessage, + /** Start creating a new entity */ + startCreate: () => view.startCreate(), + /** Start editing an entity */ + startEdit: (entity: unknown) => view.startEdit(entity), + /** Cancel editing */ + cancelEdit: () => view.cancelEdit(), + /** Save the current entity */ + save: () => view.save(), + /** Delete an entity */ + remove: (entity: unknown) => view.delete(entity), + }; +} diff --git a/platform/packages/vue/src/composables/useEntityPicker.ts b/platform/packages/vue/src/composables/useEntityPicker.ts new file mode 100644 index 00000000..37e07915 --- /dev/null +++ b/platform/packages/vue/src/composables/useEntityPicker.ts @@ -0,0 +1,53 @@ +// useEntityPicker: composable for creating and managing a VueEntityPickerView + +import { onMounted } from 'vue'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import { VueEntityPickerView } from '../views/VueEntityPickerView.js'; + +/** Options for useEntityPicker composable */ +export interface UseEntityPickerOptions { + /** Pre-loaded view descriptor */ + descriptor: ViewDescriptor; + /** Entity metadata */ + entityMetadata?: EntityMetadata | null; + /** Search function for querying entities */ + searcher?: (query: string) => Promise; + /** Initial selected value */ + initialValue?: unknown; +} + +/** + * Composable for creating and managing a VueEntityPickerView. + * + * Example: + *
{@code
+ * const { view, searchResults, selectedEntity, search, select, clear } = useEntityPicker({
+ *   descriptor,
+ *   searcher: async (q) => await api.search(q),
+ * });
+ * }
+ * + * @param options - UseEntityPickerOptions + * @returns Object with VueEntityPickerView and reactive state + */ +export function useEntityPicker(options: UseEntityPickerOptions) { + const view = new VueEntityPickerView(options.descriptor, options.entityMetadata ?? null); + + if (options.searcher) view.setSearcher(options.searcher); + + onMounted(async () => { + await view.initialize(); + if (options.initialValue !== undefined) view.setValue(options.initialValue); + }); + + return { + view, + searchResults: view.searchResults, + selectedEntity: view.selectedEntity, + searchQuery: view.searchQuery, + loading: view.isLoading, + search: (query: string) => view.search(query), + select: (entity: unknown) => view.select(entity), + clear: () => view.clear(), + }; +} diff --git a/platform/packages/vue/src/composables/useForm.ts b/platform/packages/vue/src/composables/useForm.ts new file mode 100644 index 00000000..6c176f08 --- /dev/null +++ b/platform/packages/vue/src/composables/useForm.ts @@ -0,0 +1,64 @@ +// useForm: composable for creating and managing a VueFormView + +import { onMounted } from 'vue'; +import type { ViewDescriptor } from '@dynamia-tools/sdk'; +import type { EntityMetadata } from '@dynamia-tools/sdk'; +import { VueFormView } from '../views/VueFormView.js'; + +/** Options for useForm composable */ +export interface UseFormOptions { + /** Pre-loaded view descriptor */ + descriptor: ViewDescriptor; + /** Entity metadata */ + entityMetadata?: EntityMetadata | null; + /** Initial form data */ + initialData?: Record; +} + +/** + * Composable for creating and managing a VueFormView. + * Provides direct access to reactive form state. + * + * Example: + *
{@code
+ * const { view, values, errors, validate, submit } = useForm({ descriptor, initialData: book });
+ * }
+ * + * @param options - UseFormOptions with descriptor and optional initial data + * @returns Object with VueFormView and reactive state + */ +export function useForm(options: UseFormOptions) { + const view = new VueFormView(options.descriptor, options.entityMetadata ?? null); + + onMounted(async () => { + await view.initialize(); + if (options.initialData) view.setValue(options.initialData); + }); + + return { + /** The VueFormView instance */ + view, + /** Reactive field values */ + values: view.values, + /** Reactive validation errors */ + errors: view.errors, + /** Reactive loading state */ + loading: view.isLoading, + /** Reactive dirty flag */ + isDirty: view.isDirty, + /** Reactive resolved fields */ + fields: view.resolvedFields, + /** Reactive computed layout */ + layout: view.layout, + /** Validate the form */ + validate: () => view.validate(), + /** Submit the form */ + submit: () => view.submit(), + /** Reset the form */ + reset: () => view.reset(), + /** Set a field value */ + setFieldValue: (field: string, value: unknown) => view.setFieldValue(field, value), + /** Get a field value */ + getFieldValue: (field: string) => view.getFieldValue(field), + }; +} diff --git a/platform/packages/vue/src/composables/useNavigation.ts b/platform/packages/vue/src/composables/useNavigation.ts new file mode 100644 index 00000000..600a38d5 --- /dev/null +++ b/platform/packages/vue/src/composables/useNavigation.ts @@ -0,0 +1,88 @@ +// useNavigation: composable for accessing and managing the application navigation tree + +import { ref, computed, onMounted } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; +import type { DynamiaClient, NavigationTree, NavigationModule, NavigationPage } from '@dynamia-tools/sdk'; + +// Module-level cache (singleton, not Pinia) +let _cachedTree: NavigationTree | null = null; + +/** + * Composable for accessing the application navigation tree. + * Fetches from the backend on first use and caches in memory. + * Uses SDK types directly — no new navigation types. + * + * Example: + *
{@code
+ * const { tree, currentModule, navigateTo } = useNavigation(client);
+ * }
+ * + * @param client - DynamiaClient instance + * @returns Object with reactive navigation state + */ +export function useNavigation(client: DynamiaClient) { + const tree: Ref = ref(_cachedTree); + const loading: Ref = ref(false); + const error: Ref = ref(null); + const currentPath: Ref = ref(null); + + const modules: ComputedRef = computed(() => tree.value?.modules ?? []); + + const currentModule: ComputedRef = computed(() => { + if (!currentPath.value || !tree.value) return null; + return tree.value.modules.find(m => + m.groups.some(g => g.pages.some(p => p.virtualPath === currentPath.value)) + ) ?? null; + }); + + const currentPage: ComputedRef = computed(() => { + if (!currentPath.value || !tree.value) return null; + for (const m of tree.value.modules) { + for (const g of m.groups) { + for (const p of g.pages) { + if (p.virtualPath === currentPath.value) return p; + } + } + } + return null; + }); + + async function loadNavigation(): Promise { + if (_cachedTree) { tree.value = _cachedTree; return; } + loading.value = true; + error.value = null; + try { + const navTree = await client.metadata.getNavigation(); + _cachedTree = navTree; + tree.value = navTree; + } catch (e) { + error.value = String(e); + } finally { + loading.value = false; + } + } + + function navigateTo(path: string): void { + currentPath.value = path; + } + + function clearCache(): void { + _cachedTree = null; + tree.value = null; + } + + onMounted(loadNavigation); + + return { + tree, + modules, + currentModule, + currentPage, + currentPath, + loading, + error, + navigateTo, + clearCache, + reload: loadNavigation, + }; +} diff --git a/platform/packages/vue/src/composables/useTable.ts b/platform/packages/vue/src/composables/useTable.ts new file mode 100644 index 00000000..b4b97fd6 --- /dev/null +++ b/platform/packages/vue/src/composables/useTable.ts @@ -0,0 +1,78 @@ +// useTable: composable for creating and managing a VueTableView + +import { onMounted } from 'vue'; +import type { ViewDescriptor, CrudPageable, EntityMetadata } from '@dynamia-tools/sdk'; +import { VueTableView } from '../views/VueTableView.js'; + +/** Options for useTable composable */ +export interface UseTableOptions { + /** Pre-loaded view descriptor */ + descriptor: ViewDescriptor; + /** Entity metadata */ + entityMetadata?: EntityMetadata | null; + /** Data loader function */ + loader?: (params: Record) => Promise<{ rows: unknown[]; pagination: CrudPageable | null }>; + /** Whether to load data on mount */ + autoLoad?: boolean; +} + +/** + * Composable for creating and managing a VueTableView. + * Provides direct access to reactive table state. + * + * Example: + *
{@code
+ * const { view, rows, columns, pagination, load, sort, search } = useTable({
+ *   descriptor,
+ *   loader: async (params) => { ... }
+ * });
+ * }
+ * + * @param options - UseTableOptions with descriptor and optional loader + * @returns Object with VueTableView and reactive state + */ +export function useTable(options: UseTableOptions) { + const view = new VueTableView(options.descriptor, options.entityMetadata ?? null); + + if (options.loader) { + view.setLoader(options.loader); + } + + onMounted(async () => { + await view.initialize(); + if (options.autoLoad !== false) await view.load(); + }); + + return { + /** The VueTableView instance */ + view, + /** Reactive table rows */ + rows: view.rows, + /** Reactive column definitions */ + columns: view.columns, + /** Reactive pagination info */ + pagination: view.pagination, + /** Reactive loading state */ + loading: view.isLoading, + /** Reactive selected row */ + selectedRow: view.selectedRow, + /** Reactive sort field */ + sortField: view.sortField, + /** Reactive sort direction */ + sortDir: view.sortDir, + /** Reactive search query */ + searchQuery: view.searchQuery, + /** Load data with optional params */ + load: (params?: Record) => view.load(params), + /** Sort by a field */ + sort: (field: string) => view.sort(field), + /** Search with a query */ + search: (query: string) => view.search(query), + /** Select a row */ + selectRow: (row: unknown) => view.selectRow(row), + /** Go to next page */ + nextPage: () => view.nextPage(), + /** Go to previous page */ + prevPage: () => view.prevPage(), + }; +} diff --git a/platform/packages/vue/src/composables/useView.ts b/platform/packages/vue/src/composables/useView.ts new file mode 100644 index 00000000..f9a566e2 --- /dev/null +++ b/platform/packages/vue/src/composables/useView.ts @@ -0,0 +1,30 @@ +// useView: generic composable for creating any VueView subclass + +import { onMounted } from 'vue'; +import type { VueView } from '../views/VueView.js'; + +/** + * Generic composable for creating and managing any VueView subclass. + * Initializes on mount and cleans up on unmount. + * + * @param factory - Factory function that creates the VueView instance + * @returns Object with view instance and lifecycle management + */ +export function useView(factory: () => T) { + const view = factory(); + + onMounted(async () => { + await view.initialize(); + }); + + return { + /** The VueView instance */ + view, + /** Reactive loading state */ + loading: view.isLoading, + /** Reactive error message */ + error: view.errorMessage, + /** Reactive initialized flag */ + initialized: view.isInitialized, + }; +} diff --git a/platform/packages/vue/src/composables/useViewer.ts b/platform/packages/vue/src/composables/useViewer.ts new file mode 100644 index 00000000..fb63f0f2 --- /dev/null +++ b/platform/packages/vue/src/composables/useViewer.ts @@ -0,0 +1,56 @@ +// useViewer: primary composable for creating and managing a VueViewer + +import { onMounted, onUnmounted } from 'vue'; +import type { ViewerConfig } from '@dynamia-tools/ui-core'; +import { VueViewer } from '../views/VueViewer.js'; + +/** + * Primary composable for creating and managing a VueViewer. + * Creates a VueViewer, initializes it on mount, and destroys it on unmount. + * + * Example: + *
{@code
+ * const { viewer, loading, error } = useViewer({
+ *   viewType: 'form',
+ *   beanClass: 'com.example.Book',
+ *   client,
+ * });
+ * }
+ * + * @param config - ViewerConfig with viewType, beanClass, descriptor, etc. + * @returns Object with viewer instance and reactive state + */ +export function useViewer(config: ViewerConfig = {}) { + const viewer = new VueViewer(config); + + onMounted(async () => { + try { + await viewer.initialize(); + } catch { + // Error is already set on viewer.error + } + }); + + onUnmounted(() => { + viewer.destroy(); + }); + + return { + /** The VueViewer instance */ + viewer, + /** Reactive loading state */ + loading: viewer.loading, + /** Reactive error message */ + error: viewer.error, + /** Reactive resolved view */ + view: viewer.currentView, + /** Reactive resolved descriptor */ + descriptor: viewer.currentDescriptor, + /** Get the primary value */ + getValue: () => viewer.getValue(), + /** Set the primary value */ + setValue: (value: unknown) => viewer.setValue(value), + /** Set read-only mode */ + setReadonly: (ro: boolean) => viewer.setReadonly(ro), + }; +} diff --git a/platform/packages/vue/src/index.ts b/platform/packages/vue/src/index.ts new file mode 100644 index 00000000..059b142d --- /dev/null +++ b/platform/packages/vue/src/index.ts @@ -0,0 +1,43 @@ +// @dynamia-tools/vue — Vue 3 adapter for Dynamia Platform UI + +// ── Vue-reactive View classes ────────────────────────────────────────────── +export { VueView } from './views/VueView.js'; +export { VueViewer } from './views/VueViewer.js'; +export { VueFormView } from './views/VueFormView.js'; +export { VueTableView } from './views/VueTableView.js'; +export { VueCrudView } from './views/VueCrudView.js'; +export { VueTreeView } from './views/VueTreeView.js'; +export { VueConfigView } from './views/VueConfigView.js'; +export { VueEntityPickerView } from './views/VueEntityPickerView.js'; + +// ── Renderers ────────────────────────────────────────────────────────────── +export { VueFormRenderer } from './renderers/VueFormRenderer.js'; +export { VueTableRenderer } from './renderers/VueTableRenderer.js'; +export { VueCrudRenderer } from './renderers/VueCrudRenderer.js'; +export { VueFieldRenderer } from './renderers/VueFieldRenderer.js'; + +// ── Composables ──────────────────────────────────────────────────────────── +export { useViewer } from './composables/useViewer.js'; +export { useView } from './composables/useView.js'; +export { useForm } from './composables/useForm.js'; +export type { UseFormOptions } from './composables/useForm.js'; +export { useTable } from './composables/useTable.js'; +export type { UseTableOptions } from './composables/useTable.js'; +export { useCrud } from './composables/useCrud.js'; +export type { UseCrudOptions } from './composables/useCrud.js'; +export { useEntityPicker } from './composables/useEntityPicker.js'; +export type { UseEntityPickerOptions } from './composables/useEntityPicker.js'; +export { useNavigation } from './composables/useNavigation.js'; + +// ── Plugin ───────────────────────────────────────────────────────────────── +export { DynamiaVue } from './plugin.js'; + +// ── Vue Components (named exports for direct use) ────────────────────────── +export { default as ViewerComponent } from './components/Viewer.vue'; +export { default as FormComponent } from './components/Form.vue'; +export { default as TableComponent } from './components/Table.vue'; +export { default as CrudComponent } from './components/Crud.vue'; +export { default as FieldComponent } from './components/Field.vue'; +export { default as ActionsComponent } from './components/Actions.vue'; +export { default as NavMenuComponent } from './components/NavMenu.vue'; +export { default as NavBreadcrumbComponent } from './components/NavBreadcrumb.vue'; diff --git a/platform/packages/vue/src/plugin.ts b/platform/packages/vue/src/plugin.ts new file mode 100644 index 00000000..a69ce843 --- /dev/null +++ b/platform/packages/vue/src/plugin.ts @@ -0,0 +1,76 @@ +// plugin.ts: Vue plugin that registers all renderers, factories and global components + +import type { App } from 'vue'; +import { ViewRendererRegistry, ViewTypes } from '@dynamia-tools/ui-core'; +import { VueFormView } from './views/VueFormView.js'; +import { VueTableView } from './views/VueTableView.js'; +import { VueCrudView } from './views/VueCrudView.js'; +import { VueTreeView } from './views/VueTreeView.js'; +import { VueConfigView } from './views/VueConfigView.js'; +import { VueEntityPickerView } from './views/VueEntityPickerView.js'; +import { VueFormRenderer } from './renderers/VueFormRenderer.js'; +import { VueTableRenderer } from './renderers/VueTableRenderer.js'; +import { VueCrudRenderer } from './renderers/VueCrudRenderer.js'; +import ViewerComponent from './components/Viewer.vue'; +import FormComponent from './components/Form.vue'; +import TableComponent from './components/Table.vue'; +import CrudComponent from './components/Crud.vue'; +import FieldComponent from './components/Field.vue'; +import ActionsComponent from './components/Actions.vue'; +import NavMenuComponent from './components/NavMenu.vue'; +import NavBreadcrumbComponent from './components/NavBreadcrumb.vue'; + +/** + * Vue plugin for Dynamia Tools. + * Registers all built-in view renderers, factories and global Vue components. + * + * Usage: + *
{@code
+ * import { DynamiaVue } from '@dynamia-tools/vue';
+ * app.use(DynamiaVue);
+ * }
+ */ +export const DynamiaVue = { + install(app: App): void { + // Register view renderers + ViewRendererRegistry.register(ViewTypes.Form, new VueFormRenderer()); + ViewRendererRegistry.register(ViewTypes.Table, new VueTableRenderer()); + ViewRendererRegistry.register(ViewTypes.Crud, new VueCrudRenderer()); + + // Register view factories + ViewRendererRegistry.registerViewFactory( + ViewTypes.Form, + (d, m) => new VueFormView(d, m) + ); + ViewRendererRegistry.registerViewFactory( + ViewTypes.Table, + (d, m) => new VueTableView(d, m) + ); + ViewRendererRegistry.registerViewFactory( + ViewTypes.Crud, + (d, m) => new VueCrudView(d, m) + ); + ViewRendererRegistry.registerViewFactory( + ViewTypes.Tree, + (d, m) => new VueTreeView(d, m) + ); + ViewRendererRegistry.registerViewFactory( + ViewTypes.Config, + (d, m) => new VueConfigView(d, m) + ); + ViewRendererRegistry.registerViewFactory( + ViewTypes.EntityPicker, + (d, m) => new VueEntityPickerView(d, m) + ); + + // Register global components + app.component('DynamiaViewer', ViewerComponent); + app.component('DynamiaForm', FormComponent); + app.component('DynamiaTable', TableComponent); + app.component('DynamiaCrud', CrudComponent); + app.component('DynamiaField', FieldComponent); + app.component('DynamiaActions', ActionsComponent); + app.component('DynamiaNavMenu', NavMenuComponent); + app.component('DynamiaNavBreadcrumb', NavBreadcrumbComponent); + }, +}; diff --git a/platform/packages/vue/src/renderers/VueCrudRenderer.ts b/platform/packages/vue/src/renderers/VueCrudRenderer.ts new file mode 100644 index 00000000..6ad94d6b --- /dev/null +++ b/platform/packages/vue/src/renderers/VueCrudRenderer.ts @@ -0,0 +1,18 @@ +// VueCrudRenderer: implements CrudRenderer for Vue + +import type { Component } from 'vue'; +import { ViewTypes } from '@dynamia-tools/ui-core'; +import type { CrudRenderer } from '@dynamia-tools/ui-core'; +import type { CrudView } from '@dynamia-tools/ui-core'; + +/** + * Vue implementation of CrudRenderer. + * Returns the Crud Vue component for rendering a CrudView. + */ +export class VueCrudRenderer implements CrudRenderer { + readonly supportedViewType = ViewTypes.Crud; + + render(_view: CrudView): Component { + return { name: 'DynamiaCrud' } as Component; + } +} diff --git a/platform/packages/vue/src/renderers/VueFieldRenderer.ts b/platform/packages/vue/src/renderers/VueFieldRenderer.ts new file mode 100644 index 00000000..d652c111 --- /dev/null +++ b/platform/packages/vue/src/renderers/VueFieldRenderer.ts @@ -0,0 +1,20 @@ +// VueFieldRenderer: implements FieldRenderer for individual fields + +import type { Component } from 'vue'; +import type { FieldRenderer, ResolvedField, FormView } from '@dynamia-tools/ui-core'; + +/** + * Vue implementation of FieldRenderer. + * Dispatches to the correct field component based on FieldComponent type. + */ +export class VueFieldRenderer implements FieldRenderer { + readonly supportedComponent: string; + + constructor(supportedComponent: string) { + this.supportedComponent = supportedComponent; + } + + render(_field: ResolvedField, _view: FormView): Component { + return { name: `Dynamia-${this.supportedComponent}` } as Component; + } +} diff --git a/platform/packages/vue/src/renderers/VueFormRenderer.ts b/platform/packages/vue/src/renderers/VueFormRenderer.ts new file mode 100644 index 00000000..9068dd23 --- /dev/null +++ b/platform/packages/vue/src/renderers/VueFormRenderer.ts @@ -0,0 +1,19 @@ +// VueFormRenderer: implements FormRenderer for Vue + +import type { Component } from 'vue'; +import { ViewTypes } from '@dynamia-tools/ui-core'; +import type { FormRenderer } from '@dynamia-tools/ui-core'; +import type { FormView } from '@dynamia-tools/ui-core'; + +/** + * Vue implementation of FormRenderer. + * Returns the Form Vue component for rendering a FormView. + */ +export class VueFormRenderer implements FormRenderer { + readonly supportedViewType = ViewTypes.Form; + + render(_view: FormView): Component { + // Lazy import to avoid circular dependency at module initialization time + return { name: 'DynamiaForm' } as Component; + } +} diff --git a/platform/packages/vue/src/renderers/VueTableRenderer.ts b/platform/packages/vue/src/renderers/VueTableRenderer.ts new file mode 100644 index 00000000..2901e3f6 --- /dev/null +++ b/platform/packages/vue/src/renderers/VueTableRenderer.ts @@ -0,0 +1,18 @@ +// VueTableRenderer: implements TableRenderer for Vue + +import type { Component } from 'vue'; +import { ViewTypes } from '@dynamia-tools/ui-core'; +import type { TableRenderer } from '@dynamia-tools/ui-core'; +import type { TableView } from '@dynamia-tools/ui-core'; + +/** + * Vue implementation of TableRenderer. + * Returns the Table Vue component for rendering a TableView. + */ +export class VueTableRenderer implements TableRenderer { + readonly supportedViewType = ViewTypes.Table; + + render(_view: TableView): Component { + return { name: 'DynamiaTable' } as Component; + } +} diff --git a/platform/packages/vue/src/views/VueConfigView.ts b/platform/packages/vue/src/views/VueConfigView.ts new file mode 100644 index 00000000..1d6ac92e --- /dev/null +++ b/platform/packages/vue/src/views/VueConfigView.ts @@ -0,0 +1,38 @@ +// VueConfigView: Vue-reactive extension of ConfigView + +import { ref } from 'vue'; +import type { Ref } from 'vue'; +import { ConfigView } from '@dynamia-tools/ui-core'; +import type { ConfigParameter } from '@dynamia-tools/ui-core'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; + +/** + * Vue-reactive extension of ConfigView. + */ +export class VueConfigView extends ConfigView { + /** Reactive parameters list */ + readonly parameters: Ref = ref([]); + /** Reactive parameter values */ + readonly values: Ref> = ref({}); + /** Reactive loading state */ + readonly isLoading: Ref = ref(false); + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(descriptor, entityMetadata); + } + + override async loadParameters(): Promise { + this.isLoading.value = true; + try { + await super.loadParameters(); + this.parameters.value = super.getParameters(); + } finally { + this.isLoading.value = false; + } + } + + override setParameterValue(name: string, value: unknown): void { + super.setParameterValue(name, value); + this.values.value[name] = value; + } +} diff --git a/platform/packages/vue/src/views/VueCrudView.ts b/platform/packages/vue/src/views/VueCrudView.ts new file mode 100644 index 00000000..4a6c9056 --- /dev/null +++ b/platform/packages/vue/src/views/VueCrudView.ts @@ -0,0 +1,88 @@ +// VueCrudView: Vue-reactive extension of CrudView + +import { ref, computed } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; +import { CrudView } from '@dynamia-tools/ui-core'; +import type { CrudMode } from '@dynamia-tools/ui-core'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import { VueFormView } from './VueFormView.js'; +import { VueTableView } from './VueTableView.js'; + +/** + * Vue-reactive extension of CrudView. + * Owns Vue-reactive VueFormView and VueTableView instances. + * + * Example: + *
{@code
+ * const crud = new VueCrudView(descriptor, metadata);
+ * await crud.initialize();
+ * crud.startCreate();
+ * }
+ */ +export class VueCrudView extends CrudView { + /** Reactive CRUD mode */ + readonly mode: Ref = ref('list'); + /** Reactive loading state */ + readonly isLoading: Ref = ref(false); + /** Reactive error message */ + readonly errorMessage: Ref = ref(null); + + // Override with Vue-reactive subtypes. The initializers run after super(), so + // this.descriptor and this.entityMetadata are already set by View's constructor. + override readonly formView: VueFormView = new VueFormView(this.descriptor, this.entityMetadata); + override readonly tableView: VueTableView = new VueTableView(this.descriptor, this.entityMetadata); + + /** Whether the form is currently shown */ + readonly showForm: ComputedRef = computed(() => + this.mode.value === 'create' || this.mode.value === 'edit' + ); + + /** Whether the table is currently shown */ + readonly showTable: ComputedRef = computed(() => + this.mode.value === 'list' + ); + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(descriptor, entityMetadata); + // formView and tableView are replaced by the field initializers above + } + + override async initialize(): Promise { + this.isLoading.value = true; + try { + await Promise.all([this.formView.initialize(), this.tableView.initialize()]); + this.state.initialized = true; + } catch (e) { + this.errorMessage.value = String(e); + } finally { + this.isLoading.value = false; + } + } + + override startCreate(): void { + super.startCreate(); + this.mode.value = 'create'; + } + + override startEdit(entity: unknown): void { + super.startEdit(entity); + this.mode.value = 'edit'; + } + + override cancelEdit(): void { + super.cancelEdit(); + this.mode.value = 'list'; + } + + override async save(): Promise { + this.isLoading.value = true; + try { + await super.save(); + this.mode.value = 'list'; + } catch (e) { + this.errorMessage.value = String(e); + } finally { + this.isLoading.value = false; + } + } +} diff --git a/platform/packages/vue/src/views/VueEntityPickerView.ts b/platform/packages/vue/src/views/VueEntityPickerView.ts new file mode 100644 index 00000000..893060db --- /dev/null +++ b/platform/packages/vue/src/views/VueEntityPickerView.ts @@ -0,0 +1,47 @@ +// VueEntityPickerView: Vue-reactive extension of EntityPickerView + +import { ref } from 'vue'; +import type { Ref } from 'vue'; +import { EntityPickerView } from '@dynamia-tools/ui-core'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; + +/** + * Vue-reactive extension of EntityPickerView. + */ +export class VueEntityPickerView extends EntityPickerView { + /** Reactive search results */ + readonly searchResults: Ref = ref([]); + /** Reactive selected entity */ + readonly selectedEntity: Ref = ref(null); + /** Reactive search query */ + readonly searchQuery: Ref = ref(''); + /** Reactive loading state */ + readonly isLoading: Ref = ref(false); + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(descriptor, entityMetadata); + } + + override async search(query: string): Promise { + this.searchQuery.value = query; + this.isLoading.value = true; + try { + await super.search(query); + this.searchResults.value = super.getSearchResults(); + } finally { + this.isLoading.value = false; + } + } + + override select(entity: unknown): void { + super.select(entity); + this.selectedEntity.value = entity; + } + + override clear(): void { + super.clear(); + this.selectedEntity.value = null; + this.searchQuery.value = ''; + this.searchResults.value = []; + } +} diff --git a/platform/packages/vue/src/views/VueFormView.ts b/platform/packages/vue/src/views/VueFormView.ts new file mode 100644 index 00000000..dd516400 --- /dev/null +++ b/platform/packages/vue/src/views/VueFormView.ts @@ -0,0 +1,82 @@ +// VueFormView: Vue-reactive extension of FormView + +import { ref, computed } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; +import { FormView, FieldResolver, LayoutEngine } from '@dynamia-tools/ui-core'; +import type { ResolvedField, ResolvedLayout } from '@dynamia-tools/ui-core'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; + +/** + * Vue-reactive extension of FormView. + * Exposes reactive refs for values, errors, loading and layout. + * + * Example: + *
{@code
+ * const form = new VueFormView(descriptor, metadata);
+ * await form.initialize();
+ * form.values.value['name'] = 'John';
+ * }
+ */ +export class VueFormView extends FormView { + /** Reactive form field values */ + readonly values: Ref> = ref({}); + /** Reactive field validation errors */ + readonly errors: Ref> = ref({}); + /** Reactive loading state */ + readonly isLoading: Ref = ref(false); + /** Reactive initialized flag */ + readonly isInitialized: Ref = ref(false); + /** Reactive dirty flag */ + readonly isDirty: Ref = ref(false); + + private readonly _resolvedFieldsRef: Ref = ref([]); + private readonly _layoutRef: Ref = ref(null); + + /** Reactive resolved fields list */ + readonly resolvedFields: ComputedRef = computed(() => this._resolvedFieldsRef.value); + + /** Reactive computed layout */ + readonly layout: ComputedRef = computed(() => this._layoutRef.value); + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(descriptor, entityMetadata); + } + + override async initialize(): Promise { + this.isLoading.value = true; + try { + this._resolvedFieldsRef.value = FieldResolver.resolveFields(this.descriptor, this.entityMetadata); + this._layoutRef.value = LayoutEngine.computeLayout(this.descriptor, this._resolvedFieldsRef.value); + this.isInitialized.value = true; + this.state.initialized = true; + } finally { + this.isLoading.value = false; + } + } + + override setValue(value: unknown): void { + super.setValue(value); + if (value && typeof value === 'object') { + this.values.value = { ...(value as Record) }; + } + } + + override setFieldValue(field: string, value: unknown): void { + super.setFieldValue(field, value); + this.values.value[field] = value; + this.isDirty.value = true; + } + + override validate(): boolean { + const result = super.validate(); + this.errors.value = { ...super.getErrors() }; + return result; + } + + override reset(): void { + super.reset(); + this.values.value = {}; + this.errors.value = {}; + this.isDirty.value = false; + } +} diff --git a/platform/packages/vue/src/views/VueTableView.ts b/platform/packages/vue/src/views/VueTableView.ts new file mode 100644 index 00000000..3d9c0f20 --- /dev/null +++ b/platform/packages/vue/src/views/VueTableView.ts @@ -0,0 +1,88 @@ +// VueTableView: Vue-reactive extension of TableView + +import { ref, computed } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; +import { TableView, FieldResolver } from '@dynamia-tools/ui-core'; +import type { ResolvedField, TableState } from '@dynamia-tools/ui-core'; +import type { CrudPageable } from '@dynamia-tools/sdk'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; + +/** + * Vue-reactive extension of TableView. + * Exposes reactive refs for rows, columns and pagination. + * + * Example: + *
{@code
+ * const table = new VueTableView(descriptor, metadata);
+ * await table.initialize();
+ * await table.load();
+ * }
+ */ +export class VueTableView extends TableView { + /** Reactive table rows */ + readonly rows: Ref = ref([]); + /** Reactive pagination info */ + readonly pagination: Ref = ref(null); + /** Reactive loading state */ + readonly isLoading: Ref = ref(false); + /** Reactive selected row */ + readonly selectedRow: Ref = ref(null); + /** Reactive sort field */ + readonly sortField: Ref = ref(null); + /** Reactive sort direction */ + readonly sortDir: Ref<'asc' | 'desc' | null> = ref(null); + /** Reactive search query */ + readonly searchQuery: Ref = ref(''); + /** Reactive initialized flag */ + readonly isInitialized: Ref = ref(false); + + private readonly _columnsRef: Ref = ref([]); + + /** Reactive computed columns */ + readonly columns: ComputedRef = computed(() => this._columnsRef.value); + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(descriptor, entityMetadata); + } + + override async initialize(): Promise { + this._columnsRef.value = FieldResolver.resolveFields(this.descriptor, this.entityMetadata); + this.isInitialized.value = true; + this.state.initialized = true; + } + + override async load(params: Record = {}): Promise { + this.isLoading.value = true; + try { + await super.load(params); + this.rows.value = [...super.getRows()]; + this.pagination.value = super.getPagination(); + } finally { + this.isLoading.value = false; + } + } + + override setSource(source: unknown): void { + super.setSource(source); + if (Array.isArray(source)) { + this.rows.value = source; + } + } + + override selectRow(row: unknown): void { + super.selectRow(row); + this.selectedRow.value = row; + } + + override async sort(field: string): Promise { + await super.sort(field); + const tableState = super.getState() as TableState; + this.sortField.value = tableState.sortField ?? null; + this.sortDir.value = tableState.sortDir ?? null; + } + + override async search(query: string): Promise { + this.searchQuery.value = query; + await super.search(query); + } +} diff --git a/platform/packages/vue/src/views/VueTreeView.ts b/platform/packages/vue/src/views/VueTreeView.ts new file mode 100644 index 00000000..29d7c530 --- /dev/null +++ b/platform/packages/vue/src/views/VueTreeView.ts @@ -0,0 +1,47 @@ +// VueTreeView: Vue-reactive extension of TreeView + +import { ref } from 'vue'; +import type { Ref } from 'vue'; +import { TreeView } from '@dynamia-tools/ui-core'; +import type { TreeNode } from '@dynamia-tools/ui-core'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; + +/** + * Vue-reactive extension of TreeView. + */ +export class VueTreeView extends TreeView { + /** Reactive tree nodes */ + readonly nodes: Ref = ref([]); + /** Reactive expanded node IDs */ + readonly expandedIds: Ref> = ref(new Set()); + /** Reactive selected node ID */ + readonly selectedId: Ref = ref(null); + + constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(descriptor, entityMetadata); + } + + override async initialize(): Promise { + await super.initialize(); + } + + override setSource(source: unknown): void { + super.setSource(source); + if (Array.isArray(source)) this.nodes.value = source as TreeNode[]; + } + + override expand(node: TreeNode): void { + super.expand(node); + this.expandedIds.value = new Set(this.state.expandedNodeIds); + } + + override collapse(node: TreeNode): void { + super.collapse(node); + this.expandedIds.value = new Set(this.state.expandedNodeIds); + } + + override selectNode(node: TreeNode): void { + super.selectNode(node); + this.selectedId.value = node.id; + } +} diff --git a/platform/packages/vue/src/views/VueView.ts b/platform/packages/vue/src/views/VueView.ts new file mode 100644 index 00000000..e1eb0af7 --- /dev/null +++ b/platform/packages/vue/src/views/VueView.ts @@ -0,0 +1,39 @@ +// VueView: abstract Vue-reactive extension of ui-core View + +import { ref } from 'vue'; +import type { Ref } from 'vue'; +import { View } from '@dynamia-tools/ui-core'; +import type { ViewType } from '@dynamia-tools/ui-core'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; + +/** + * Abstract base class for all Vue-reactive views. + * Extends ui-core View to add Vue reactivity primitives. + */ +export abstract class VueView extends View { + /** Reactive loading state */ + readonly isLoading: Ref = ref(false); + /** Reactive error message */ + readonly errorMessage: Ref = ref(null); + /** Reactive initialized flag */ + readonly isInitialized: Ref = ref(false); + + constructor(viewType: ViewType, descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) { + super(viewType, descriptor, entityMetadata); + } + + protected setLoading(loading: boolean): void { + this.isLoading.value = loading; + this.state.loading = loading; + } + + protected setError(error: string | null): void { + this.errorMessage.value = error; + this.state.error = error; + } + + protected setInitialized(initialized: boolean): void { + this.isInitialized.value = initialized; + this.state.initialized = initialized; + } +} diff --git a/platform/packages/vue/src/views/VueViewer.ts b/platform/packages/vue/src/views/VueViewer.ts new file mode 100644 index 00000000..8637d10e --- /dev/null +++ b/platform/packages/vue/src/views/VueViewer.ts @@ -0,0 +1,54 @@ +// VueViewer: Vue-reactive extension of ui-core Viewer + +import { ref, shallowRef } from 'vue'; +import type { Ref, ShallowRef } from 'vue'; +import { Viewer } from '@dynamia-tools/ui-core'; +import type { ViewerConfig, View } from '@dynamia-tools/ui-core'; +import type { ViewDescriptor } from '@dynamia-tools/sdk'; + +/** + * Vue-reactive extension of ui-core Viewer. + * Wraps Viewer with reactive state for Vue templates. + * + * Example: + *
{@code
+ * const viewer = new VueViewer({ viewType: 'form', beanClass: 'com.example.Book', client });
+ * await viewer.initialize();
+ * // viewer.currentView.value is now reactive
+ * }
+ */ +export class VueViewer extends Viewer { + /** Reactive loading state */ + readonly loading: Ref = ref(false); + /** Reactive error message */ + readonly error: Ref = ref(null); + /** Reactive resolved view instance */ + readonly currentView: ShallowRef = shallowRef(null); + /** Reactive resolved descriptor */ + readonly currentDescriptor: ShallowRef = shallowRef(null); + + constructor(config: ViewerConfig = {}) { + super(config); + } + + override async initialize(): Promise { + this.loading.value = true; + this.error.value = null; + try { + await super.initialize(); + this.currentView.value = super.view; + this.currentDescriptor.value = super.resolvedDescriptor; + } catch (e) { + this.error.value = String(e); + throw e; + } finally { + this.loading.value = false; + } + } + + override destroy(): void { + super.destroy(); + this.currentView.value = null; + this.currentDescriptor.value = null; + } +} diff --git a/platform/packages/vue/tsconfig.build.json b/platform/packages/vue/tsconfig.build.json new file mode 100644 index 00000000..33777c7c --- /dev/null +++ b/platform/packages/vue/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "declaration": true, + "declarationMap": true, + "jsx": "preserve" + }, + "include": ["src"] +} diff --git a/platform/packages/vue/tsconfig.json b/platform/packages/vue/tsconfig.json new file mode 100644 index 00000000..2daf4622 --- /dev/null +++ b/platform/packages/vue/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "jsx": "preserve", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler" + }, + "include": ["src"] +} diff --git a/platform/packages/vue/vite.config.ts b/platform/packages/vue/vite.config.ts new file mode 100644 index 00000000..e45df81b --- /dev/null +++ b/platform/packages/vue/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import dts from 'vite-plugin-dts'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [ + vue(), + dts({ + include: ['src'], + outDir: 'dist', + insertTypesEntry: true, + tsconfigPath: './tsconfig.build.json', + }), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'DynamiaVue', + formats: ['es', 'cjs'], + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), + }, + rollupOptions: { + external: ['vue', '@dynamia-tools/sdk', '@dynamia-tools/ui-core'], + output: { + globals: { + vue: 'Vue', + '@dynamia-tools/sdk': 'DynamiaSdk', + '@dynamia-tools/ui-core': 'DynamiaUiCore', + }, + }, + }, + sourcemap: true, + minify: false, + }, +}); From 758180865a862bdc6ac3d1e41c071bb767eb4480 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:03:15 +0000 Subject: [PATCH 05/61] =?UTF-8?q?fix:=20clarify=20Longbox=E2=86=92Intbox?= =?UTF-8?q?=20mapping=20in=20Field.vue=20component=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- platform/packages/vue/src/components/Field.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/platform/packages/vue/src/components/Field.vue b/platform/packages/vue/src/components/Field.vue index 0a481572..55760878 100644 --- a/platform/packages/vue/src/components/Field.vue +++ b/platform/packages/vue/src/components/Field.vue @@ -45,6 +45,7 @@ defineEmits<{ const componentMap: Record Promise> = { [FieldComponents.Textbox]: () => import('./fields/Textbox.vue'), [FieldComponents.Intbox]: () => import('./fields/Intbox.vue'), + // Longbox (64-bit integer) shares the integer input component; browser inputs handle the range [FieldComponents.Longbox]: () => import('./fields/Intbox.vue'), [FieldComponents.Decimalbox]: () => import('./fields/Spinner.vue'), [FieldComponents.Spinner]: () => import('./fields/Spinner.vue'), From 2b39d5913a0b767ec4ee2bd803b8ebd2a5cf2d82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:07:34 +0000 Subject: [PATCH 06/61] feat: implement @dynamia-tools/ui-core and @dynamia-tools/vue packages Co-authored-by: marioserrano09 <5221275+marioserrano09@users.noreply.github.com> --- .../packages/ui-core/src/viewer/Viewer.ts | 4 +-- .../packages/vue/src/components/Field.vue | 2 +- .../packages/vue/src/components/Table.vue | 2 +- .../vue/src/components/fields/Datebox.vue | 9 +++++- .../vue/src/components/fields/Textareabox.vue | 32 +++++++++++++++++++ .../packages/vue/src/composables/useViewer.ts | 7 ++-- platform/packages/vue/src/views/VueViewer.ts | 2 +- 7 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 platform/packages/vue/src/components/fields/Textareabox.vue diff --git a/platform/packages/ui-core/src/viewer/Viewer.ts b/platform/packages/ui-core/src/viewer/Viewer.ts index a68666d7..203e8973 100644 --- a/platform/packages/ui-core/src/viewer/Viewer.ts +++ b/platform/packages/ui-core/src/viewer/Viewer.ts @@ -121,8 +121,8 @@ export class Viewer { const entities = await this.client.metadata.getEntities(); const entity = entities.entities.find(e => e.className === this.beanClass); if (entity) this._actions = entity.actions; - } catch { - // Ignore — actions are optional + } catch (_e) { + // Ignore — actions are optional; entity metadata lookup may fail for dynamic beanClass } } diff --git a/platform/packages/vue/src/components/Field.vue b/platform/packages/vue/src/components/Field.vue index 55760878..09fc3265 100644 --- a/platform/packages/vue/src/components/Field.vue +++ b/platform/packages/vue/src/components/Field.vue @@ -57,7 +57,7 @@ const componentMap: Record Promise> = { [FieldComponents.EntityRefLabel]: () => import('./fields/EntityRefLabel.vue'), [FieldComponents.CoolLabel]: () => import('./fields/CoolLabel.vue'), [FieldComponents.Link]: () => import('./fields/Link.vue'), - [FieldComponents.Textareabox]: () => import('./fields/Textbox.vue'), + [FieldComponents.Textareabox]: () => import('./fields/Textareabox.vue'), }; const resolvedFieldComponent = computed(() => { diff --git a/platform/packages/vue/src/components/Table.vue b/platform/packages/vue/src/components/Table.vue index e9036868..6e14fb5b 100644 --- a/platform/packages/vue/src/components/Table.vue +++ b/platform/packages/vue/src/components/Table.vue @@ -43,7 +43,7 @@ - + No records found diff --git a/platform/packages/vue/src/components/fields/Datebox.vue b/platform/packages/vue/src/components/fields/Datebox.vue index 1f84b626..1cb5768b 100644 --- a/platform/packages/vue/src/components/fields/Datebox.vue +++ b/platform/packages/vue/src/components/fields/Datebox.vue @@ -29,7 +29,14 @@ const inputType = computed(() => props.params?.['time'] ? 'datetime-local' : 'da const formattedValue = computed(() => { if (!props.modelValue) return ''; try { - const d = new Date(props.modelValue as string | number); + let d: Date; + if (props.modelValue instanceof Date) { + d = props.modelValue; + } else if (typeof props.modelValue === 'string' || typeof props.modelValue === 'number') { + d = new Date(props.modelValue); + } else { + return String(props.modelValue); + } if (isNaN(d.getTime())) return String(props.modelValue); return inputType.value === 'datetime-local' ? d.toISOString().slice(0, 16) diff --git a/platform/packages/vue/src/components/fields/Textareabox.vue b/platform/packages/vue/src/components/fields/Textareabox.vue new file mode 100644 index 00000000..10fbb2bc --- /dev/null +++ b/platform/packages/vue/src/components/fields/Textareabox.vue @@ -0,0 +1,32 @@ + +