diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index f71466c3..88000974 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,60 +1,70 @@
# Copilot Instructions for Dynamia Tools (Framework Internal)
-These guidelines are for contributing to the **Dynamia Tools framework itself**, not for applications that use the framework.
+These guidelines are for contributing to the **Dynamia Tools framework itself**, not for applications that use the framework.
The focus is on keeping the codebase consistent, maintainable, and well-documented.
+## IDE Integration Always use the `intellij-index` MCP server when applicable for:
+- **Finding references** — Use `ide_find_references` instead of grep/search
+- **Go to definition** — Use `ide_find_definition` for accurate navigation
+- **Renaming symbols** — Use `ide_refactor_rename` for safe, project-wide renames
+- **Type hierarchy** — Use `ide_type_hierarchy` to understand class relationships
+- **Finding implementations** — Use `ide_find_implementations` for interfaces/abstract classes
+- **Diagnostics** — Use `ide_diagnostics` to check for code problems The IDE's semantic understanding is far more accurate than text-based search.
+- Prefer IDE tools over grep, ripgrep, or manual file searching when working with code symbols.
+
+
---
## Project Structure
The framework is organized into modules. Each module has a specific responsibility:
-- **actions**
+- **actions**
Handles platform actions, implementing operations users can perform (create, update, delete entities).
-- **app**
+- **app**
Main application module, orchestrating the integration of all other modules and providing the entry point.
-- **commons**
+- **commons**
Contains shared utilities and common code used across multiple modules to avoid duplication.
-- **crud**
+- **crud**
Provides generic Create, Read, Update, Delete functionalities for entities, simplifying data management.
-- **domain**
+- **domain**
Defines core business entities and domain logic, serving as the foundation for other modules.
-- **domain-jpa**
+- **domain-jpa**
Adds JPA (Java Persistence API) support for domain entities, enabling ORM and database integration.
-- **integration**
+- **integration**
Manages integration with external systems and services, handling communication and data exchange.
-- **io**
+- **io**
Responsible for input/output operations, such as file handling and data streams.
-- **navigation**
+- **navigation**
Implements navigation logic and structures for the application's user interface.
-- **reports**
+- **reports**
Generates and manages reports, providing tools for data analysis and export.
-- **starter**
+- **starter**
Offers starter templates and configurations to bootstrap new projects or modules.
-- **templates**
+- **templates**
Contains reusable templates for UI, emails, or documents.
-- **ui**
+- **ui**
Manages user interface components and visual elements.
-- **viewers**
+- **viewers**
Provides components for viewing and presenting data in various formats.
-- **web**
+- **web**
Exposes web functionalities, including REST endpoints and web resources.
-- **zk**
+- **zk**
Integrates ZK framework components for building rich web interfaces.
---
@@ -68,6 +78,42 @@ The framework is organized into modules. Each module has a specific responsibili
---
+## JavaScript/TypeScript (SDK + Vue) Guidelines
+
+When generating frontend code for Dynamia Platform, prefer the current APIs from:
+
+- `platform/packages/sdk/src/index.ts`
+- `platform/packages/sdk/src/client.ts`
+- `platform/packages/vue/src/index.ts`
+- `platform/packages/vue/src/plugin.ts`
+
+### `@dynamia-tools/sdk`
+
+- Use `new DynamiaClient({ baseUrl, token? })` as the entry point.
+- Prefer `baseUrl` as app origin (for example `https://app.example.com`), because SDK endpoints already include `/api/...` internally.
+- Use `client.metadata.getNavigation()` for menus/routing (shape: `NavigationTree.navigation`, not `modules/groups/pages`).
+- Use `client.crud(path)` for `CrudPage` virtual paths (`findAll`, `findById`, `create`, `update`, `delete`).
+- Use `client.crudService(className)` only for class-name based `/crud-service` use cases.
+- `findAll()` returns `CrudListResult` with `content`, `total`, `page`, `pageSize`, `totalPages`.
+- Handle API failures with `DynamiaApiError` (`status`, `url`, `body`).
+
+### `@dynamia-tools/vue`
+
+- Register the plugin once: `app.use(DynamiaVue)`.
+- Use global components provided by the plugin: `DynamiaViewer`, `DynamiaForm`, `DynamiaTable`, `DynamiaCrud`, `DynamiaCrudPage`, `DynamiaNavMenu`, `DynamiaNavBreadcrumb`, etc.
+- Prefer composables over manual wiring: `useViewer`, `useView`, `useForm`, `useTable`, `useCrud`, `useCrudPage`, `useEntityPicker`, `useNavigation`.
+- For app shells driven by navigation, use `useNavigation(client)` and render by node type.
+- For nodes with `node.type === 'CrudPage'`, render with `DynamiaCrudPage` or wire with `useCrudPage`.
+- In menu/breadcrumb code use `NavigationNode.internalPath` and `children`.
+
+### Accuracy Rules for Generated Examples
+
+- Do not invent SDK or Vue APIs that are not exported from the package `index.ts` files.
+- Keep examples aligned with real return types (for example `CrudListResult`, `NavigationNode`).
+- If an API is uncertain, prefer a short TODO comment over guessing a method/signature.
+
+---
+
## Documentation Guidelines (Javadoc)
- Every class and public method must have **Javadoc in English**.
@@ -133,7 +179,7 @@ Start fast with DynamiaTools
## Installation
-1. Create a new SpringBoot project using [start.spring.io](https://start.spring.io) and select Web, JPA and a programming language for your Spring Boot app.
+1. Create a new SpringBoot project using [start.spring.io](https://start.spring.io) and select Web, JPA and a programming language for your Spring Boot app.
DynamiaTools is compatible with Java, Groovy and Kotlin.
You can also [click here](https://start.spring.io/#!type=maven-project&language=java&packaging=jar&jvmVersion=21&groupId=com.example&artifactId=dynamia-tools-project&name=Dynaima&description=Demo%20project%20for%20Spring%20Boot&packageName=com.example.demo&dependencies=web,data-jpa,h2) to get a preconfigured Spring Boot project with Java, Maven and Web with JPA support.
@@ -183,7 +229,7 @@ class MyApplication {
}
```
-After running the application, open your browser and go to http://localhost:8080.
+After running the application, open your browser and go to http://localhost:8080.
You should see a blank page with a fully functional HTML template called Dynamical.
---
@@ -264,14 +310,14 @@ public class ContactModuleProvider implements ModuleProvider {
}
```
-Modules include ID, name, pages and page groups. Pages include ID, name and path, which in this case is represented by an entity class.
+Modules include ID, name, pages and page groups. Pages include ID, name and path, which in this case is represented by an entity class.
For example, the `Contact` crud page has the path `/pages/my-module/contacts`.
---
### 3. View descriptors
-Descriptors are YAML files defining how views for entities are rendered at runtime.
+Descriptors are YAML files defining how views for entities are rendered at runtime.
Create a folder `/resources/META-INF/descriptors` and a file `ContactForm.yml`:
```yaml
@@ -310,7 +356,7 @@ fields:
### 4. Run and enjoy
-Your app now has a new menu called *My Module* and a submenu called *Contacts*.
+Your app now has a new menu called *My Module* and a submenu called *Contacts*.
This is a fully functional CRUD with create, read, update, delete, and many more ready-to-use actions.
---
@@ -335,7 +381,7 @@ dynamia:
### Automatic REST
-Every `CrudPage` automatically generates a REST endpoint.
+Every `CrudPage` automatically generates a REST endpoint.
Example: `http://localhost:8080/api/my-module/contacts`
```json
@@ -368,3 +414,4 @@ With this guide, you’ve just built a web application with:
- A responsive template
Continue with the advanced guides to explore more features of DynamiaTools.
+
diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml
index a55f16dc..4d6c6979 100644
--- a/.github/workflows/publish-npm.yml
+++ b/.github/workflows/publish-npm.yml
@@ -2,20 +2,17 @@ name: Publish NPM Packages
on:
release:
- types: [published] # triggers when a release is published on GitHub
- workflow_dispatch: # allows manual trigger from the Actions tab
+ types: [published]
+ workflow_dispatch:
jobs:
publish:
name: Build, Test & Publish to NPM
runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: platform/packages
permissions:
contents: read
- id-token: write # required for npm provenance
+ id-token: write
steps:
- name: Checkout repository
@@ -33,19 +30,18 @@ jobs:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
- cache-dependency-path: platform/packages/pnpm-lock.yaml
+ cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build all packages
- run: pnpm build
+ run: pnpm -r build
- name: Run tests
- run: pnpm test
+ run: pnpm -r test
- name: Publish all packages to NPM
run: pnpm -r publish --access public --no-git-checks
env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 00000000..57ebd549
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,3 @@
+access=public
+registry=https://registry.npmjs.org/
+
diff --git a/README.md b/README.md
index 126d5ca5..34a6e104 100644
--- a/README.md
+++ b/README.md
@@ -16,16 +16,16 @@
### 📅 CalVer Versioning
-Starting with version **26.2.2**, Dynamia Platform adopts **Calendar Versioning (CalVer)** with the format `YY.MM.MINOR`. This means:
+Starting with version **26.4.0**, Dynamia Platform adopts **Calendar Versioning (CalVer)** with the format `YY.MM.MINOR`. This means:
- **All modules share the same version**: Core, extensions, starters, themes—everything is released together
-- **26.2.2** = First release of February 2026 (Year 26, Month 02, Release 0)
+- **26.4.0** = First release of February 2026 (Year 26, Month 02, Release 0)
- **26.2.1** = Second release of February 2026
- **26.3.0** = First release of March 2026
- **Unified releases** ensure compatibility and simplify dependency management
- No more version mismatches between platform components!
**Examples**:
-- `26.2.2` → February 2026, first release
+- `26.4.0` → February 2026, first release
- `26.2.1` → February 2026, second release (hotfix or minor update)
- `26.12.3` → December 2026, fourth release
@@ -230,19 +230,19 @@ Enterprise authentication and authorization:
tools.dynamia
tools.dynamia.app
- 26.2.2
+ 26.4.0
tools.dynamia
tools.dynamia.zk
- 26.2.2
+ 26.4.0
tools.dynamia
tools.dynamia.domain.jpa
- 26.2.2
+ 26.4.0
```
@@ -250,9 +250,9 @@ Enterprise authentication and authorization:
**Gradle** (`build.gradle`)
```groovy
dependencies {
- implementation 'tools.dynamia:tools.dynamia.app:26.2.2'
- implementation 'tools.dynamia:tools.dynamia.zk:26.2.2'
- implementation 'tools.dynamia:tools.dynamia.domain.jpa:26.2.2'
+ implementation 'tools.dynamia:tools.dynamia.app:26.4.0'
+ implementation 'tools.dynamia:tools.dynamia.zk:26.4.0'
+ implementation 'tools.dynamia:tools.dynamia.domain.jpa:26.4.0'
}
```
@@ -291,65 +291,65 @@ Enterprise authentication and authorization:
### Adding Extensions
-To use any of the built-in extensions, simply add their dependencies. **All extensions now share the same version (26.2.2)** thanks to unified CalVer:
+To use any of the built-in extensions, simply add their dependencies. **All extensions now share the same version (26.4.0)** thanks to unified CalVer:
```xml
tools.dynamia.modules
tools.dynamia.modules.saas
- 26.2.2
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.email
- 26.2.2
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.entityfiles
- 26.2.2
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.entityfiles.s3
- 26.2.2
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.dashboard
- 26.2.2
+ 26.4.0
tools.dynamia.reports
tools.dynamia.reports.core
- 26.2.2
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.fileimporter
- 26.2.2
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.security
- 26.2.2
+ 26.4.0
```
-> **💡 Pro Tip**: With CalVer, all Dynamia Platform components use the same version. Just use `26.2.2` for everything!
+> **💡 Pro Tip**: With CalVer, all Dynamia Platform components use the same version. Just use `26.4.0` for everything!
> **Note**: All artifacts are available on [Maven Central](https://search.maven.org/search?q=tools.dynamia)
@@ -472,7 +472,7 @@ Java 11+ and ecosystem update:
- 🚀 **Spring Boot 4** - Next-gen Spring ecosystem
- 🎨 **ZK 10+** - Modern web UI capabilities
- 🔄 **Synchronized Releases** - Core, extensions, starters, and themes share the same version
-- 🎯 **Simplified Dependencies** - One version to rule them all (e.g., 26.2.2 for February 2026)
+- 🎯 **Simplified Dependencies** - One version to rule them all (e.g., 26.4.0 for February 2026)
- ⚡ **Enhanced Performance** - Optimized for modern JVM and cloud environments
- 🛡️ **Production Hardened** - Battle-tested in enterprise environments
diff --git a/docs/backend/ADVANCED_TOPICS.md b/docs/backend/ADVANCED_TOPICS.md
new file mode 100644
index 00000000..bcef9362
--- /dev/null
+++ b/docs/backend/ADVANCED_TOPICS.md
@@ -0,0 +1,1033 @@
+# Advanced Topics
+
+This document covers advanced concepts for building enterprise-grade applications with DynamiaTools, including deep Spring integration, custom extension development, security, caching, and deployment patterns.
+
+## Table of Contents
+
+1. [Advanced Spring Integration](#advanced-spring-integration)
+2. [Custom Extension Development](#custom-extension-development)
+3. [Custom View Renderers](#custom-view-renderers)
+4. [Security Integration](#security-integration)
+5. [Caching & Performance](#caching--performance)
+6. [Event System](#event-system)
+7. [Scheduled Tasks](#scheduled-tasks)
+8. [REST API Development](#rest-api-development)
+9. [Progressive Web Apps (PWA)](#progressive-web-apps-pwa)
+10. [Modularity & Microservices](#modularity--microservices)
+
+---
+
+## Advanced Spring Integration
+
+### Bean Lifecycle Hooks
+
+Use standard Spring lifecycle annotations to integrate with the DynamiaTools startup:
+
+```java
+@Component
+public class AppInitializer {
+
+ @Autowired
+ private CrudService crudService;
+
+ @PostConstruct
+ public void init() {
+ // Runs once after the bean is fully initialized
+ // Good place to register defaults, seed data checks, etc.
+ }
+
+ @PreDestroy
+ public void teardown() {
+ // Cleanup resources before context shutdown
+ }
+}
+```
+
+### CommandLineRunner for Startup Logic
+
+Use `CommandLineRunner` (or `ApplicationRunner`) to run logic after the application starts:
+
+```java
+@Component
+@Order(1)
+public class InitSampleDataCLR implements CommandLineRunner {
+
+ private final CrudService crudService;
+
+ public InitSampleDataCLR(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ @Override
+ public void run(String... args) {
+ if (crudService.count(Category.class) == 0) {
+ crudService.save(new Category("Fiction"));
+ crudService.save(new Category("Science"));
+ crudService.save(new Category("Technology"));
+ }
+ }
+}
+```
+
+### Conditional Beans
+
+Use `@ConditionalOnProperty` to enable or disable beans based on configuration:
+
+```java
+@Configuration
+@ConditionalOnProperty(name = "myapp.feature.advanced-search", havingValue = "true")
+public class AdvancedSearchConfig {
+
+ @Bean
+ public AdvancedSearchService advancedSearchService() {
+ return new AdvancedSearchServiceImpl();
+ }
+}
+```
+
+### Multiple Bean Implementations
+
+When multiple implementations of the same interface exist, use `@Qualifier` or `@Primary`:
+
+```java
+public interface ReportGenerator {
+ byte[] generate(Report report);
+}
+
+@Service
+@Primary
+public class PdfReportGenerator implements ReportGenerator {
+ @Override
+ public byte[] generate(Report report) { /* PDF generation */ }
+}
+
+@Service
+public class ExcelReportGenerator implements ReportGenerator {
+ @Override
+ public byte[] generate(Report report) { /* Excel generation */ }
+}
+
+// Injection with qualifier
+@Service
+public class ReportService {
+
+ private final ReportGenerator defaultGenerator;
+
+ @Qualifier("excelReportGenerator")
+ private final ReportGenerator excelGenerator;
+
+ public ReportService(ReportGenerator defaultGenerator,
+ @Qualifier("excelReportGenerator") ReportGenerator excelGenerator) {
+ this.defaultGenerator = defaultGenerator;
+ this.excelGenerator = excelGenerator;
+ }
+}
+```
+
+### Application Properties with Custom Prefix
+
+Define strongly-typed configuration using `@ConfigurationProperties`:
+
+```java
+@ConfigurationProperties(prefix = "myapp")
+public class MyAppConfigProperties {
+
+ private String name;
+ private boolean featureEnabled;
+ private int maxConnections = 10;
+ private Map settings = new HashMap<>();
+
+ // Getters and setters
+}
+
+// Enable in configuration class
+@Configuration
+@EnableConfigurationProperties(MyAppConfigProperties.class)
+public class MyAppConfiguration { }
+```
+
+**`application.yml`**:
+```yaml
+myapp:
+ name: My Enterprise App
+ feature-enabled: true
+ max-connections: 20
+ settings:
+ theme: dark
+ locale: es
+```
+
+### DynamiaTools Application Properties
+
+The `dynamia.app.*` prefix is the main configuration namespace:
+
+```yaml
+dynamia:
+ app:
+ name: My Application
+ short-name: MyApp
+ version: 1.0.0
+ description: Enterprise application built with DynamiaTools
+ company: Acme Corp
+ author: Dev Team
+ url: https://myapp.example.com
+ template: Dynamical
+ default-skin: Green
+ default-logo: /static/images/logo.png
+ default-icon: /static/images/icon.png
+ base-package: com.example.myapp
+ api-base-path: /api/v1
+ web-cache-enabled: true
+ build: ${timestamp}
+```
+
+---
+
+## Custom Extension Development
+
+Extensions allow you to add reusable features that can be activated across multiple applications.
+
+### Extension Structure
+
+```
+my-extension/
+├── sources/
+│ ├── api/ # Public interfaces and DTOs
+│ │ └── pom.xml
+│ ├── core/ # Business logic implementation
+│ │ └── pom.xml
+│ └── ui/ # ZK or web UI components
+│ └── pom.xml
+└── pom.xml
+```
+
+### 1. Define the API (interfaces)
+
+```java
+// my-extension/sources/api/
+public interface NotificationService {
+ void send(Notification notification);
+ List findPending(String recipient);
+}
+
+public class Notification {
+ private String recipient;
+ private String subject;
+ private String body;
+ private NotificationType type;
+ // Getters/Setters
+}
+
+public enum NotificationType { EMAIL, SMS, PUSH }
+```
+
+### 2. Implement the Core
+
+```java
+// my-extension/sources/core/
+@Service
+public class DefaultNotificationService implements NotificationService {
+
+ private final CrudService crudService;
+
+ public DefaultNotificationService(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ @Override
+ public void send(Notification notification) {
+ // Persist and send the notification
+ crudService.save(notification);
+ }
+
+ @Override
+ public List findPending(String recipient) {
+ return crudService.findAll(Notification.class, "recipient", recipient);
+ }
+}
+```
+
+### 3. Register a Module Provider (optional)
+
+```java
+@Provider
+public class NotificationModuleProvider implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ return new Module("notifications", "Notifications")
+ .icon("bell")
+ .addPage(new CrudPage("notifications", "Notifications", Notification.class));
+ }
+}
+```
+
+### 4. Use the Extension in Your Application
+
+```xml
+
+
+ com.example
+ my-extension-core
+ 1.0.0
+
+```
+
+```java
+@Service
+public class OrderService {
+
+ private final NotificationService notificationService;
+
+ public OrderService(NotificationService notificationService) {
+ this.notificationService = notificationService;
+ }
+
+ public void placeOrder(Order order) {
+ // ... save order
+ notificationService.send(new Notification(
+ order.getCustomer().getEmail(),
+ "Order Confirmed",
+ "Your order #" + order.getNumber() + " has been received."
+ ));
+ }
+}
+```
+
+### Extension Points Pattern
+
+Provide interfaces that users of your extension can implement:
+
+```java
+// In the extension API
+public interface NotificationFilter {
+ boolean shouldSend(Notification notification);
+}
+
+// In the extension core – discover all filters automatically
+@Service
+public class FilteredNotificationService implements NotificationService {
+
+ private final List filters;
+
+ public FilteredNotificationService(List filters) {
+ this.filters = filters;
+ }
+
+ @Override
+ public void send(Notification notification) {
+ boolean allowed = filters.stream().allMatch(f -> f.shouldSend(notification));
+ if (allowed) {
+ // send
+ }
+ }
+}
+
+// Application code provides its own filter
+@Component
+public class BusinessHoursFilter implements NotificationFilter {
+ @Override
+ public boolean shouldSend(Notification n) {
+ int hour = LocalTime.now().getHour();
+ return hour >= 8 && hour < 18;
+ }
+}
+```
+
+---
+
+## Custom View Renderers
+
+The default view renderer uses ZK, but you can create renderers for any frontend technology.
+
+### Implementing ViewRenderer
+
+```java
+@Component
+public class ReactViewRenderer implements ViewRenderer {
+
+ @Override
+ public String getViewType() {
+ return "react-form"; // Maps to view: react-form in YAML
+ }
+
+ @Override
+ public Object render(ViewDescriptor descriptor, Object value) {
+ // Build the component tree for React
+ return buildReactComponent(descriptor, value);
+ }
+}
+```
+
+### View Descriptor for Custom Renderer
+
+```yaml
+# ContactForm.yml
+view: react-form
+beanClass: com.example.Contact
+fields:
+ name:
+ label: Full Name
+ email:
+ label: Email Address
+```
+
+### ZK ViewCustomizer (Advanced)
+
+Override the runtime rendering of any view by implementing `ViewCustomizer>`:
+
+```java
+public class InvoiceFormCustomizer implements ViewCustomizer> {
+
+ @Override
+ public void customize(FormView view) {
+ FormFieldComponent detailsField = view.getFieldComponent("details");
+
+ // React to value changes
+ view.addEventListener(FormView.ON_VALUE_CHANGED, event -> {
+ Invoice invoice = view.getValue();
+ if (invoice != null) {
+ detailsField.setVisible(invoice.getCustomer() != null);
+ }
+ });
+ }
+}
+```
+
+Register the customizer in your YAML descriptor:
+
+```yaml
+view: form
+beanClass: com.example.Invoice
+customizer: com.example.customizers.InvoiceFormCustomizer
+fields:
+ customer:
+ details:
+ component: crudview
+ params:
+ inplace: true
+```
+
+---
+
+## Security Integration
+
+DynamiaTools integrates with Spring Security for authentication and authorization.
+
+### Basic Spring Security Configuration
+
+```java
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/api/public/**").permitAll()
+ .requestMatchers("/api/admin/**").hasRole("ADMIN")
+ .anyRequest().authenticated()
+ )
+ .formLogin(form -> form
+ .loginPage("/login")
+ .defaultSuccessUrl("/")
+ .permitAll()
+ )
+ .logout(logout -> logout
+ .logoutSuccessUrl("/login?logout")
+ .permitAll()
+ );
+ return http.build();
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
+```
+
+### Role-Based Access in Actions
+
+```java
+@InstallAction
+public class DeleteUserAction extends AbstractCrudAction {
+
+ public DeleteUserAction() {
+ setName("Delete User");
+ setApplicableClass(User.class);
+ }
+
+ @Override
+ public boolean isVisible(ActionEvent event) {
+ // Only show for admins
+ return SecurityContextHolder.getContext()
+ .getAuthentication()
+ .getAuthorities()
+ .stream()
+ .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
+ }
+
+ @Override
+ @Secured("ROLE_ADMIN")
+ public void actionPerformed(CrudActionEvent evt) {
+ User user = (User) evt.getEntity();
+ crudService().delete(user);
+ }
+}
+```
+
+### Getting Current User
+
+```java
+@Service
+public class AuditService {
+
+ public UserInfo getCurrentUser() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null && auth.isAuthenticated()) {
+ return new UserInfo(auth.getName(), auth.getAuthorities());
+ }
+ return UserInfo.anonymous();
+ }
+}
+```
+
+### OAuth2 / OIDC Integration
+
+```java
+@Configuration
+@EnableWebSecurity
+public class OAuth2SecurityConfig {
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .oauth2Login(oauth2 -> oauth2
+ .defaultSuccessUrl("/")
+ .failureUrl("/login?error")
+ )
+ .oauth2ResourceServer(rs -> rs.jwt());
+ return http.build();
+ }
+}
+```
+
+**`application.yml`**:
+```yaml
+spring:
+ security:
+ oauth2:
+ client:
+ registration:
+ google:
+ client-id: ${GOOGLE_CLIENT_ID}
+ client-secret: ${GOOGLE_CLIENT_SECRET}
+ scope: openid, profile, email
+```
+
+---
+
+## Caching & Performance
+
+### Enabling Ehcache 3
+
+DynamiaTools provides `Ehcache3CacheManager` for out-of-the-box caching:
+
+```java
+@SpringBootApplication
+@EnableCaching
+public class MyApplication {
+
+ @Bean
+ public CacheManager cacheManager() {
+ return new Ehcache3CacheManager();
+ }
+}
+```
+
+### Caching Service Results
+
+```java
+@Service
+public class CategoryService {
+
+ private final CrudService crudService;
+
+ public CategoryService(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ @Cacheable("categories")
+ public List getAllCategories() {
+ return crudService.findAll(Category.class);
+ }
+
+ @CacheEvict(value = "categories", allEntries = true)
+ public Category save(Category category) {
+ return crudService.save(category);
+ }
+}
+```
+
+### Entity Reference Repository with Caching
+
+DynamiaTools provides `DefaultEntityReferenceRepository` to expose named lookup lists (used in dropdowns):
+
+```java
+@Bean
+public EntityReferenceRepository categoryReferenceRepository() {
+ return new DefaultEntityReferenceRepository<>(Category.class, "name");
+}
+```
+
+### Query Optimization
+
+```java
+@Service
+public class BookService {
+
+ private final CrudService crudService;
+
+ public BookService(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ // Use projections to avoid loading full entities
+ public List> getBookTitlesAndISBNs() {
+ return crudService.executeQuery(
+ "SELECT b.id, b.title, b.isbn FROM Book b ORDER BY b.title"
+ );
+ }
+
+ // Use pagination for large datasets
+ public Page getBooksPage(int page, int size) {
+ return crudService.findAll(Book.class, PageRequest.of(page, size));
+ }
+}
+```
+
+---
+
+## Event System
+
+DynamiaTools and Spring provide a rich event system for decoupled communication.
+
+### Publishing Events
+
+```java
+@Service
+public class OrderService {
+
+ private final ApplicationEventPublisher eventPublisher;
+ private final CrudService crudService;
+
+ public OrderService(ApplicationEventPublisher eventPublisher, CrudService crudService) {
+ this.eventPublisher = eventPublisher;
+ this.crudService = crudService;
+ }
+
+ public Order placeOrder(Order order) {
+ Order saved = crudService.save(order);
+ eventPublisher.publishEvent(new OrderPlacedEvent(saved));
+ return saved;
+ }
+}
+
+// Custom event
+public class OrderPlacedEvent extends ApplicationEvent {
+
+ private final Order order;
+
+ public OrderPlacedEvent(Order order) {
+ super(order);
+ this.order = order;
+ }
+
+ public Order getOrder() {
+ return order;
+ }
+}
+```
+
+### Listening to Events
+
+```java
+@Component
+public class OrderNotificationListener {
+
+ private final EmailService emailService;
+
+ public OrderNotificationListener(EmailService emailService) {
+ this.emailService = emailService;
+ }
+
+ @EventListener
+ public void onOrderPlaced(OrderPlacedEvent event) {
+ Order order = event.getOrder();
+ emailService.sendOrderConfirmation(order.getCustomer().getEmail(), order);
+ }
+
+ // Async listener
+ @Async
+ @EventListener
+ public void onOrderPlacedAsync(OrderPlacedEvent event) {
+ // Long-running notification task
+ }
+}
+```
+
+### CRUD Lifecycle Events
+
+Use `CrudServiceListenerAdapter` to hook into entity lifecycle:
+
+```java
+@Listener
+public class BookCrudListener extends CrudServiceListenerAdapter {
+
+ @Override
+ public void beforeCreate(Book entity) {
+ // Called before INSERT
+ if (entity.getPublishDate() == null) {
+ entity.setPublishDate(LocalDate.now());
+ }
+ }
+
+ @Override
+ public void afterCreate(Book entity) {
+ // Called after INSERT
+ }
+
+ @Override
+ public void beforeUpdate(Book entity) {
+ // Called before UPDATE
+ entity.setLastModified(LocalDateTime.now());
+ }
+
+ @Override
+ public void beforeDelete(Book entity) {
+ // Called before DELETE
+ if (entity.hasActiveOrders()) {
+ throw new ValidationError("Cannot delete a book with active orders");
+ }
+ }
+}
+```
+
+---
+
+## Scheduled Tasks
+
+Use Spring's `@Scheduled` annotation with `@EnableScheduling` on the main class:
+
+```java
+@SpringBootApplication
+@EnableScheduling
+public class MyApplication { }
+```
+
+```java
+@Component
+public class DailyReportJob {
+
+ private final ReportService reportService;
+ private final EmailService emailService;
+
+ public DailyReportJob(ReportService reportService, EmailService emailService) {
+ this.reportService = reportService;
+ this.emailService = emailService;
+ }
+
+ // Run every day at 8:00 AM
+ @Scheduled(cron = "0 0 8 * * *")
+ public void sendDailyReport() {
+ byte[] report = reportService.generateDailyReport();
+ emailService.sendReportToManagers(report);
+ }
+
+ // Run every 5 minutes
+ @Scheduled(fixedDelay = 300_000)
+ public void processQueuedNotifications() {
+ // Process pending notifications
+ }
+}
+```
+
+### Long-Running Operations with Progress Monitoring
+
+For operations that may take a long time, use `LongOperationMonitorWindow`:
+
+```java
+@InstallAction
+public class ProcessAllBooksAction extends AbstractCrudAction {
+
+ public ProcessAllBooksAction() {
+ setName("Process All Books");
+ setApplicableClass(Book.class);
+ }
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ LongOperationMonitorWindow.start("Processing Books", "Done", monitor -> {
+ List books = crudService().findAll(Book.class);
+ monitor.setMax(books.size());
+
+ for (Book book : books) {
+ monitor.setMessage("Processing: " + book.getTitle());
+ processBook(book);
+ monitor.increment();
+
+ if (monitor.isStopped()) {
+ throw new ValidationError("Operation cancelled.");
+ }
+ }
+ });
+ }
+
+ private void processBook(Book book) {
+ // Long processing logic
+ }
+}
+```
+
+---
+
+## REST API Development
+
+DynamiaTools provides the `@EnableDynamiaToolsApi` annotation and `CrudServiceRestClient` to build and consume REST APIs.
+
+### Exposing Entity REST Endpoints
+
+```java
+@SpringBootApplication
+@EnableDynamiaTools
+@EnableDynamiaToolsApi
+public class MyApplication { }
+```
+
+### Custom REST Controllers
+
+```java
+@RestController
+@RequestMapping("/api/books")
+public class BookRestController {
+
+ private final CrudService crudService;
+
+ public BookRestController(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ @GetMapping
+ public List getAll() {
+ return crudService.findAll(Book.class);
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getById(@PathVariable Long id) {
+ Book book = crudService.find(Book.class, id);
+ return book != null
+ ? ResponseEntity.ok(book)
+ : ResponseEntity.notFound().build();
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public Book create(@RequestBody @Valid Book book) {
+ return crudService.save(book);
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity update(@PathVariable Long id, @RequestBody Book updated) {
+ Book existing = crudService.find(Book.class, id);
+ if (existing == null) return ResponseEntity.notFound().build();
+ updated.setId(id);
+ return ResponseEntity.ok(crudService.save(updated));
+ }
+
+ @DeleteMapping("/{id}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void delete(@PathVariable Long id) {
+ Book book = crudService.find(Book.class, id);
+ if (book != null) crudService.delete(book);
+ }
+}
+```
+
+### API Configuration
+
+```yaml
+dynamia:
+ app:
+ api-base-path: /api/v1
+
+# Spring MVC
+spring:
+ mvc:
+ pathmatch:
+ matching-strategy: ant_path_matcher
+```
+
+### DTO Pattern
+
+Avoid exposing JPA entities directly in REST APIs:
+
+```java
+public record BookDTO(Long id, String title, String isbn, BigDecimal price, String categoryName) {
+ public static BookDTO from(Book book) {
+ return new BookDTO(
+ book.getId(),
+ book.getTitle(),
+ book.getIsbn(),
+ book.getPrice(),
+ book.getCategory() != null ? book.getCategory().getName() : null
+ );
+ }
+}
+
+@GetMapping
+public List getAll() {
+ return crudService.findAll(Book.class).stream()
+ .map(BookDTO::from)
+ .toList();
+}
+```
+
+---
+
+## Progressive Web Apps (PWA)
+
+DynamiaTools has built-in PWA support via `PWAManifest`.
+
+### Configuring PWA Manifest
+
+```java
+@Bean
+public PWAManifest manifest() {
+ return PWAManifest.builder()
+ .name("My Book Store")
+ .shortName("Books")
+ .startUrl("/")
+ .backgroundColor("#ffffff")
+ .themeColor("#3f51b5")
+ .display("standalone")
+ .categories(List.of("books", "education"))
+ .addIcon(PWAIcon.builder()
+ .src("android-chrome-192x192.png")
+ .sizes("192x192")
+ .type("image/png")
+ .build())
+ .addIcon(PWAIcon.builder()
+ .src("android-chrome-512x512.png")
+ .sizes("512x512")
+ .type("image/png")
+ .build())
+ .addShortcut(PWAShortcut.builder()
+ .name("Books")
+ .shortName("Books")
+ .description("Go to Books")
+ .url("/library/books")
+ .build())
+ .build();
+}
+```
+
+### Service Worker Integration
+
+The framework auto-registers a service worker when `PWAManifest` bean is present. Static assets are cached automatically.
+
+### Making the App Installable
+
+Once `PWAManifest` is configured and served over HTTPS, the browser will prompt users to install the app. No additional code is required.
+
+---
+
+## Modularity & Microservices
+
+### Packaging Modules as Independent JARs
+
+Each module in your application should be a separate Maven module:
+
+```
+my-enterprise-app/
+├── app/ # Spring Boot main module
+├── crm/ # CRM module JAR
+├── inventory/ # Inventory module JAR
+├── billing/ # Billing module JAR
+└── pom.xml
+```
+
+Each module JAR contributes `ModuleProvider`, entities, services, and descriptors. When included as a dependency in `app/`, it auto-registers.
+
+### Module Discovery
+
+DynamiaTools discovers modules via Spring's `@Provider` / `@Component` scanning. Just add the module JAR to the classpath and the module appears automatically:
+
+```java
+// In crm.jar
+@Provider
+public class CrmModuleProvider implements ModuleProvider {
+ @Override
+ public Module getModule() {
+ return new Module("crm", "CRM")
+ .addPage(new CrudPage("contacts", "Contacts", Contact.class));
+ }
+}
+```
+
+### Cross-Module Communication
+
+Prefer events over direct service calls to keep modules decoupled:
+
+```java
+// CRM module publishes an event
+eventPublisher.publishEvent(new CustomerCreatedEvent(customer));
+
+// Billing module listens
+@EventListener
+public void onCustomerCreated(CustomerCreatedEvent event) {
+ billingService.createBillingProfile(event.getCustomer());
+}
+```
+
+### Conditional Modules
+
+Load modules conditionally based on configuration:
+
+```java
+@Provider
+@ConditionalOnProperty(name = "myapp.modules.crm", havingValue = "true", matchIfMissing = true)
+public class CrmModuleProvider implements ModuleProvider {
+ // ...
+}
+```
+
+**`application.yml`**:
+```yaml
+myapp:
+ modules:
+ crm: true
+ billing: false
+```
+
+---
+
+## Summary
+
+Advanced DynamiaTools development leverages:
+
+- **Spring Boot** capabilities: lifecycle hooks, conditional beans, typed properties
+- **Custom Extensions**: independent JARs with API/core/UI layers and extension points
+- **Custom Renderers**: plug in any frontend technology via `ViewRenderer`
+- **Spring Security**: role-based access, OAuth2/OIDC, method security
+- **Caching**: `Ehcache3CacheManager` and `@Cacheable` for performance
+- **Events**: decoupled communication via `ApplicationEventPublisher` and `@EventListener`
+- **REST APIs**: standard Spring MVC controllers + DTOs + `@EnableDynamiaToolsApi`
+- **PWA**: zero-config installable apps via `PWAManifest` bean
+- **Modularity**: independent JARs for each domain module, auto-discovered at runtime
+
+---
+
+Next: Read [Examples & Integration](./EXAMPLES.md) for complete real-world code examples.
+
diff --git a/docs/backend/ARCHITECTURE.md b/docs/backend/ARCHITECTURE.md
new file mode 100644
index 00000000..b2962176
--- /dev/null
+++ b/docs/backend/ARCHITECTURE.md
@@ -0,0 +1,682 @@
+# Backend Architecture Overview
+
+This document explains the architectural design of DynamiaTools, the layered approach, core design principles, and how components interact.
+
+## Table of Contents
+
+1. [Architectural Philosophy](#architectural-philosophy)
+2. [Layered Architecture](#layered-architecture)
+3. [Core Layers Explained](#core-layers-explained)
+4. [Module System](#module-system)
+5. [Request Processing Flow](#request-processing-flow)
+6. [Dependency Injection & Spring Integration](#dependency-injection--spring-integration)
+7. [Design Patterns Used](#design-patterns-used)
+
+---
+
+## Architectural Philosophy
+
+DynamiaTools follows several core architectural principles:
+
+### 1. **DRY (Don't Repeat Yourself)**
+The framework minimizes boilerplate through automation and conventions. Define an entity once, get CRUD operations automatically.
+
+### 2. **Zero-Config with Sensible Defaults**
+Conventions over configuration. The framework uses reasonable defaults that work for 80% of use cases.
+
+### 3. **Modularity**
+Applications are built as collections of modules. Each module is independently deployable and testable.
+
+### 4. **Separation of Concerns**
+Clear distinction between domain logic (entities), business logic (services), data access (repositories), and presentation (views).
+
+### 5. **Abstraction Over Implementation**
+Core concepts use interfaces and abstractions, allowing implementations to be swapped (CrudService, ViewRenderer, etc.).
+
+### 6. **Spring Boot Foundation**
+Built on Spring Boot 4, leveraging Spring's dependency injection, AOP, and ecosystem.
+
+---
+
+## Layered Architecture
+
+DynamiaTools follows a **multi-layer architecture** with clear responsibilities:
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Presentation Layer (UI) │
+│ (ZK Components, REST Controllers, View Renderers) │
+├─────────────────────────────────────────────────────────┤
+│ Application Layer │
+│ (Actions, CRUD Controllers, Navigation, Descriptors) │
+├─────────────────────────────────────────────────────────┤
+│ Business Logic Layer │
+│ (Services, Validators, Domain Events) │
+├─────────────────────────────────────────────────────────┤
+│ Domain Layer │
+│ (Entities, Value Objects, Domain Logic) │
+├─────────────────────────────────────────────────────────┤
+│ Data Access Layer │
+│ (CrudService, Repositories, JPA, Query Engine) │
+├─────────────────────────────────────────────────────────┤
+│ Infrastructure Layer │
+│ (Database, File System, External Services) │
+└─────────────────────────────────────────────────────────┘
+```
+
+### Layer Responsibilities
+
+| Layer | Responsibility | Examples |
+|-------|---|---|
+| **Presentation** | Render UI, handle user interactions | ZK components, REST endpoints, view descriptors |
+| **Application** | Orchestrate operations, route requests | CRUD controllers, actions, navigation |
+| **Business Logic** | Implement business rules | Services, validators, calculations |
+| **Domain** | Model the business domain | Entities, value objects, domain logic |
+| **Data Access** | Persist and retrieve data | CrudService, repositories, queries |
+| **Infrastructure** | External systems integration | Database drivers, file storage, APIs |
+
+---
+
+## Core Layers Explained
+
+### 1. Presentation Layer
+
+**Responsibility**: Render user interfaces and handle user interactions.
+
+**Components**:
+- **View Renderers**: Convert view descriptors to UI components (ZK by default)
+- **REST Controllers**: Handle HTTP requests and API interactions
+- **ZK Components**: Rich server-side UI components
+- **View Descriptors**: YAML files defining UI structure
+
+**Example**:
+```java
+// REST Controller
+@RestController
+@RequestMapping("/api/contacts")
+public class ContactController {
+ @GetMapping
+ public List getContacts() {
+ // Returns data to be rendered
+ }
+}
+```
+
+-- View Descriptor (YAML)
+```yaml
+view: form
+beanClass: com.example.Contact
+fields:
+ name:
+ email:
+```
+
+### 2. Application Layer
+
+**Responsibility**: Orchestrate application operations, coordinate between layers, handle navigation and CRUD operations.
+
+**Components**:
+- **CRUD Controllers**: Handle entity CRUD operations
+- **Actions**: Reusable components for common operations
+- **Navigation System**: Module and page management
+- **Metadata Engine**: Exposes metadata to frontend
+
+**Example**:
+```java
+// Action: Reusable operation
+@InstallAction
+public class ExportContactsAction extends AbstractAction {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ // Export logic
+ }
+}
+
+// Module Provider: Define module structure
+@Component
+public class ContactModuleProvider implements ModuleProvider {
+ @Override
+ public Module getModule() {
+ Module module = new Module("crm", "CRM");
+ module.addPage(new CrudPage("contacts", "Contacts", Contact.class));
+ return module;
+ }
+}
+```
+
+### 3. Business Logic Layer
+
+**Responsibility**: Implement business rules, validations, and domain-specific operations.
+
+**Components**:
+- **Services**: Spring services with @Service annotation
+- **Validators**: Custom validation logic
+- **Domain Events**: Events triggered by domain operations
+- **Calculators**: Complex business calculations
+
+**Example**:
+```java
+@Service
+public class ContactService {
+
+ private final CrudService crudService;
+
+ public Contact activateContact(Long id) {
+ Contact contact = crudService.find(Contact.class, id);
+ // Business logic here
+ contact.setActive(true);
+ crudService.save(contact);
+ return contact;
+ }
+}
+
+// Custom Validator
+@InstallValidator
+public class EmailValidator implements Validator {
+ @Override
+ public List validate(Contact contact) {
+ // Validation logic
+ return validationErrors;
+ }
+}
+```
+
+### 4. Domain Layer
+
+**Responsibility**: Model the business domain with entities and value objects.
+
+**Components**:
+- **Entities**: @Entity classes with business logic
+- **Value Objects**: Immutable objects representing concepts
+- **Domain Interfaces**: Contracts for domain behavior
+
+**Example**:
+```java
+@Entity
+@Table(name = "contacts")
+public class Contact {
+ @Id
+ @GeneratedValue
+ private Long id;
+
+ private String name;
+ private String email;
+
+ @ManyToOne
+ private Company company;
+
+ // Business logic methods
+ public boolean isValid() {
+ return name != null && email != null;
+ }
+
+ // Getters and setters
+}
+```
+
+### 5. Data Access Layer
+
+**Responsibility**: Handle data persistence and retrieval from the database.
+
+**Components**:
+- **CrudService**: Main abstraction for CRUD operations
+- **JpaRepository**: Spring Data repositories
+- **Query Engine**: Complex query construction
+- **Entity Managers**: JPA entity lifecycle management
+
+**Example**:
+```java
+// Using CrudService
+@Service
+public class ContactAppService {
+
+ private final CrudService crudService;
+
+ public List findActiveContacts() {
+ // CrudService handles JPA internally
+ return crudService.findAll(Contact.class, "active", true);
+ }
+
+ public Contact createContact(Contact contact) {
+ return crudService.save(contact);
+ }
+}
+
+// CrudService is implemented by JpaCrudService
+// Which uses Spring Data JPA repositories internally
+```
+
+### 6. Infrastructure Layer
+
+**Responsibility**: Manage external systems and resources.
+
+**Components**:
+- **Database**: PostgreSQL, MySQL, H2, etc.
+- **File Storage**: Local disk, AWS S3, etc.
+- **Cache**: Redis, Memcached, etc.
+- **Message Brokers**: RabbitMQ, Kafka, etc.
+- **External APIs**: Third-party service integrations
+
+---
+
+## Module System
+
+DynamiaTools applications are organized as **modules**. Each module is a cohesive unit containing related functionality.
+
+### Module Structure
+
+```
+mymodule/
+├── src/main/java/com/example/
+│ ├── domain/ # Domain entities
+│ │ └── Contact.java
+│ ├── services/ # Business logic
+│ │ └── ContactService.java
+│ ├── ui/ # UI components (optional)
+│ ├── providers/ # Spring components
+│ │ └── ContactModuleProvider.java
+│ └── actions/ # Actions
+│ └── ExportContactsAction.java
+└── src/main/resources/
+ └── META-INF/descriptors/ # View descriptors
+ ├── ContactForm.yml
+ ├── ContactTable.yml
+ └── ContactCrud.yml
+```
+
+### Module Provider Pattern
+
+```java
+@Component
+public class ContactModuleProvider implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ Module module = new Module("crm", "CRM");
+ module.setIcon("contacts");
+
+ // Add pages
+ PageGroup customerGroup = new PageGroup("customers", "Customers");
+ customerGroup.addPage(new CrudPage("contacts", "Contacts", Contact.class));
+ customerGroup.addPage(new CrudPage("companies", "Companies", Company.class));
+ module.addPageGroup(customerGroup);
+
+ return module;
+ }
+}
+```
+
+### Module Dependencies
+
+Modules can depend on other modules:
+
+```java
+// Module A depends on Module B
+@Component
+public class ModuleA implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ Module module = new Module("moduleA", "Module A");
+
+ // Reference another module
+ Module moduleB = new Module("moduleB", "Module B");
+ moduleB.setReference(true); // Mark as reference
+ module.addSubModule(moduleB);
+
+ return module;
+ }
+}
+```
+
+---
+
+## Request Processing Flow
+
+Here's how a typical request flows through the layers:
+
+### CRUD Read Request Flow
+
+```
+1. User clicks "Edit Contact" button
+ ↓
+2. ZK Component triggers event
+ ↓
+3. CrudController receives request
+ ↓
+4. Controller calls CrudService.find(Contact.class, id)
+ ↓
+5. CrudService queries database via repository
+ ↓
+6. Repository returns entity from database
+ ↓
+7. Controller prepares response
+ ↓
+8. View Descriptor defines form layout
+ ↓
+9. View Renderer converts descriptor to ZK components
+ ↓
+10. ZK renders HTML/JavaScript to browser
+```
+
+### CRUD Create Request Flow
+
+```
+1. User submits form
+ ↓
+2. Form data reaches CrudController
+ ↓
+3. Controller instantiates Contact entity from form data
+ ↓
+4. Validators (ValidationService) validate entity
+ ↓
+5. If validation passes → CrudService.save(contact)
+ ↓
+6. Save triggers business logic services (optional listeners)
+ ↓
+7. JPA persists entity to database
+ ↓
+8. Transaction completes
+ ↓
+9. Navigation returns to list view
+```
+
+---
+
+## Dependency Injection & Spring Integration
+
+DynamiaTools is built on Spring's dependency injection. All components are managed by the Spring container.
+
+### Registering Components
+
+```java
+// Automatic registration via annotations
+@Component
+public class MyComponent {
+ // Spring manages lifecycle
+}
+
+@Service
+public class MyService {
+ // Singleton service
+}
+
+@Repository
+public interface ContactRepository extends JpaRepository {
+ // Repository for queries
+}
+
+// Custom annotation for DynamiaTools-specific registration
+@InstallAction
+public class MyAction extends AbstractAction {
+ // Installed as action
+}
+
+@InstallValidator
+public class MyValidator implements Validator {
+ // Installed as validator
+}
+```
+
+### Dependency Injection
+
+```java
+@Service
+public class ContactService {
+
+ // Constructor injection (recommended)
+ private final CrudService crudService;
+ private final EmailService emailService;
+
+ public ContactService(CrudService crudService, EmailService emailService) {
+ this.crudService = crudService;
+ this.emailService = emailService;
+ }
+
+ // Field injection (not recommended but supported)
+ @Autowired
+ private NotificationService notificationService;
+
+ // Method injection
+ @Autowired
+ public void setAuditService(AuditService auditService) {
+ this.auditService = auditService;
+ }
+}
+```
+
+### Spring Integration Points
+
+| Feature | Usage |
+|---------|-------|
+| **@Service** | Create business logic services |
+| **@Component** | Create generic Spring beans |
+| **@Repository** | Create data access objects |
+| **@Configuration** | Define Spring configurations |
+| **@Autowired** | Inject dependencies |
+| **@Qualifier** | Select specific bean implementation |
+| **@Scope** | Control bean lifecycle (singleton, prototype, etc.) |
+| **@Transactional** | Manage transactions |
+| **@Async** | Run methods asynchronously |
+| **@Scheduled** | Schedule periodic tasks |
+| **@EventListener** | Listen to Spring events |
+
+---
+
+## Design Patterns Used
+
+DynamiaTools extensively uses proven design patterns:
+
+### 1. **Provider Pattern**
+Modules are registered via `ModuleProvider` interface, allowing dynamic module discovery.
+
+```java
+@Component
+public class ContactModuleProvider implements ModuleProvider {
+ @Override
+ public Module getModule() {
+ return new Module("crm", "CRM");
+ }
+}
+```
+
+### 2. **Strategy Pattern**
+Different implementations for the same concept (CrudService, ViewRenderer).
+
+```java
+public interface CrudService {
+ T find(Class entityClass, Object id);
+ T save(T entity);
+}
+
+// Different implementations
+@Service
+public class JpaCrudService implements CrudService {
+ // JPA implementation
+}
+
+@Service
+public class MongodbCrudService implements CrudService {
+ // MongoDB implementation
+}
+```
+
+### 3. **Template Method Pattern**
+Base classes provide template implementation, subclasses override specific methods.
+
+```java
+public abstract class AbstractCrudAction extends AbstractAction {
+
+ protected abstract void actionPerformed(CrudActionEvent evt);
+
+ // Template method
+ final void execute(ActionEvent evt) {
+ if (validateAction()) {
+ actionPerformed((CrudActionEvent) evt);
+ }
+ }
+}
+```
+
+### 4. **Decorator Pattern**
+ViewCustomizers decorate view descriptors before rendering.
+
+```java
+@Component
+public class ContactFormCustomizer implements ViewCustomizer {
+
+ @Override
+ public void customize(ViewDescriptor view, Map metadata) {
+ // Modify view descriptor
+ view.getFields().get("email").setRequired(true);
+ }
+}
+```
+
+### 5. **Observer Pattern**
+Actions and validators observe CRUD operations via events.
+
+```java
+@Component
+public class AuditListener {
+
+ @EventListener
+ public void onEntityCreated(EntityCreatedEvent event) {
+ // React to entity creation
+ }
+}
+```
+
+### 6. **Factory Pattern**
+ViewRendererFactory creates appropriate renderer based on view type.
+
+```java
+public class ViewRendererFactory {
+
+ public ViewRenderer createRenderer(ViewDescriptor view) {
+ if ("form".equals(view.getType())) {
+ return new FormViewRenderer();
+ } else if ("table".equals(view.getType())) {
+ return new TableViewRenderer();
+ }
+ // ...
+ }
+}
+```
+
+### 7. **Builder Pattern**
+Fluent API for constructing complex objects.
+
+```java
+Module module = new Module("crm", "CRM")
+ .addPageGroup(new PageGroup("sales", "Sales")
+ .addPage(new CrudPage("orders", "Orders", Order.class))
+ .addPage(new CrudPage("invoices", "Invoices", Invoice.class))
+ )
+ .addPageGroup(new PageGroup("customers", "Customers")
+ .addPage(new CrudPage("contacts", "Contacts", Contact.class))
+ );
+```
+
+### 8. **Command Pattern**
+Actions encapsulate requests as objects.
+
+```java
+@InstallAction
+public class EmailContactAction extends AbstractCrudAction {
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ Contact contact = (Contact) evt.getEntity();
+ // Send email
+ }
+}
+```
+
+---
+
+## Key Architectural Principles
+
+### 1. **Convention Over Configuration**
+The framework uses naming conventions to reduce configuration. For example, view descriptors are automatically discovered from standard locations.
+
+### 2. **Metadata-Driven**
+UI generation is driven by metadata (view descriptors), not hardcoded in views.
+
+### 3. **Extensibility**
+Multiple extension points allow customization without modifying core code:
+- View Customizers
+- Actions
+- Validators
+- Custom Services
+
+### 4. **Lazy Initialization**
+Components are lazily initialized and cached for performance.
+
+### 5. **Transaction Management**
+Automatic transaction handling via Spring @Transactional.
+
+### 6. **Security Integration**
+Built-in Spring Security integration for authentication and authorization.
+
+---
+
+## Performance Considerations
+
+### Caching Strategy
+- Application metadata is cached at startup
+- Entity metadata is cached
+- View descriptors are loaded and cached on first access
+
+### Lazy Loading
+- Relationships are lazy-loaded by default
+- Use @Fetch(FetchMode.JOIN) for eager loading when needed
+
+### Query Optimization
+- Use CrudService with appropriate filters
+- Leverage Spring Data repository custom queries for complex queries
+
+### Connection Pooling
+- HikariCP connection pool for database connections
+- Configurable pool size and timeout
+
+---
+
+## Integration with Spring Boot
+
+DynamiaTools seamlessly integrates with Spring Boot:
+
+```java
+@SpringBootApplication
+@EnableDynamiaTools // Enables DynamiaTools auto-configuration
+public class MyApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(MyApplication.class, args);
+ }
+}
+```
+
+The `@EnableDynamiaTools` annotation triggers:
+1. Component scanning for ModuleProviders
+2. Initialization of metadata engine
+3. Registration of ViewRenderers
+4. Configuration of default beans (CrudService, NavigationManager, etc.)
+
+---
+
+## Summary
+
+DynamiaTools architecture is built on:
+- **Layered design** for separation of concerns
+- **Module system** for modularity and reusability
+- **Spring Boot foundation** for robust dependency injection
+- **Design patterns** for proven solutions
+- **Metadata-driven approach** for zero-code UI generation
+- **Abstraction over implementation** for flexibility
+
+This architecture enables rapid development of scalable enterprise applications while maintaining clean, maintainable code.
+
+---
+
+Next: Read [Core Modules Reference](./CORE_MODULES.md) to learn about each platform module.
+
diff --git a/docs/backend/CORE_MODULES.md b/docs/backend/CORE_MODULES.md
new file mode 100644
index 00000000..f886dbee
--- /dev/null
+++ b/docs/backend/CORE_MODULES.md
@@ -0,0 +1,1316 @@
+# Core Modules Reference
+
+This document provides detailed information about each core module in the DynamiaTools platform. Understanding these modules is essential for building applications.
+
+## Table of Contents
+
+1. [Module Organization](#module-organization)
+2. [Commons Module](#commons-module)
+3. [Domain Module](#domain-module)
+4. [Domain-JPA Module](#domain-jpa-module)
+5. [CRUD Module](#crud-module)
+6. [Navigation Module](#navigation-module)
+7. [Actions Module](#actions-module)
+8. [Integration Module](#integration-module)
+9. [Web Module](#web-module)
+10. [Reports Module](#reports-module)
+11. [Templates Module](#templates-module)
+12. [Viewers Module](#viewers-module)
+13. [App Module](#app-module)
+14. [Module Dependencies](#module-dependencies)
+
+---
+
+## Module Organization
+
+Core modules are located in `/platform/core/`:
+
+```
+platform/core/
+├── commons/ # Shared utilities and helpers
+├── domain/ # Domain model abstractions
+├── domain-jpa/ # JPA implementations
+├── crud/ # CRUD operations framework
+├── navigation/ # Navigation and module system
+├── actions/ # Action framework
+├── integration/ # Spring Boot integration
+├── web/ # Web utilities
+├── reports/ # Reporting system
+├── templates/ # Template rendering
+└── viewers/ # View rendering system
+```
+
+Each module is packaged as a separate JAR with its own dependencies and can be used independently or together.
+
+---
+
+## Commons Module
+
+**Artifact ID**: `tools.dynamia.commons`
+
+**Purpose**: Provides shared utilities, helpers, and common functionality used across the platform.
+
+### Key Features
+
+- **Utility Classes**: Common helpers for string, collection, and reflection operations
+- **Lambdas & Functional Programming**: Functional utility classes
+- **Serialization**: Custom serialization utilities
+- **Converters**: Type conversion between common types
+- **Validators**: Common validation utilities
+- **Bean Utilities**: Reflection-based bean inspection and manipulation
+- **Text Processing**: String formatting and template utilities
+
+### Common Classes
+
+```java
+// String utilities
+import tools.dynamia.commons.StringUtils;
+
+StringUtils.toTitleCase("hello world"); // "Hello World"
+StringUtils.toCamelCase("hello_world"); // "helloWorld"
+
+// Collection utilities
+import tools.dynamia.commons.CollectionUtils;
+
+CollectionUtils.isEmpty(list);
+CollectionUtils.isNotEmpty(list);
+
+// Functional utilities
+import tools.dynamia.commons.Lambdas;
+
+Supplier cached = Lambdas.memoize(() -> expensiveOperation());
+
+// Bean utilities
+import tools.dynamia.commons.BeanUtils;
+
+BeanUtils.setProperties(bean, propertyMap);
+Map props = BeanUtils.getProperties(bean);
+
+// Reflections
+import tools.dynamia.commons.ReflectionUtils;
+
+Field field = ReflectionUtils.getField(MyClass.class, "myField");
+```
+
+### When to Use Commons
+
+- Need common string operations
+- Working with collections or maps
+- Reflection-based operations
+- Type conversions
+- Bean introspection
+
+---
+
+## Domain Module
+
+**Artifact ID**: `tools.dynamia.domain`
+
+**Purpose**: Provides abstractions for domain modeling without persistence details.
+
+### Key Concepts
+
+#### 1. Entity Interface
+Base interface for all entities:
+
+```java
+import tools.dynamia.domain.Entity;
+
+public interface Entity {
+ Object getId();
+ void setId(Object id);
+ boolean isNew();
+ boolean isPersistent();
+}
+```
+
+#### 2. Identifiable
+Objects with unique identifiers:
+
+```java
+import tools.dynamia.domain.Identifiable;
+
+public interface Identifiable {
+ Serializable getId();
+}
+```
+
+#### 3. Auditable
+Entities that track creation and modification:
+
+```java
+import tools.dynamia.domain.Auditable;
+
+public interface Auditable {
+ User getCreator();
+ LocalDateTime getCreationDate();
+ User getModifier();
+ LocalDateTime getModificationDate();
+}
+```
+
+#### 4. Validator Interface
+Custom validation logic:
+
+```java
+import tools.dynamia.domain.Validator;
+
+public interface Validator {
+ List validate(T entity);
+ Class getValidatedClass();
+}
+
+// Register with annotation
+@InstallValidator
+public class ContactValidator implements Validator {
+ @Override
+ public List validate(Contact contact) {
+ List errors = new ArrayList<>();
+ if (contact.getEmail() == null) {
+ errors.add(new ValidationError("email", "Email is required"));
+ }
+ return errors;
+ }
+
+ @Override
+ public Class getValidatedClass() {
+ return Contact.class;
+ }
+}
+```
+
+#### 5. Converter Interface
+Convert between types:
+
+```java
+import tools.dynamia.domain.Converter;
+
+public interface Converter {
+ T convert(S source);
+ Class getSourceClass();
+ Class getTargetClass();
+}
+
+// Example: String to LocalDate converter
+@InstallConverter
+public class StringToLocalDateConverter implements Converter {
+ @Override
+ public LocalDate convert(String source) {
+ return LocalDate.parse(source, DateTimeFormatter.ISO_DATE);
+ }
+
+ @Override
+ public Class getSourceClass() {
+ return String.class;
+ }
+
+ @Override
+ public Class getTargetClass() {
+ return LocalDate.class;
+ }
+}
+```
+
+### Core Classes
+
+- **Entity**: Base interface for domain entities
+- **Validator**: Custom validation logic
+- **Converter**: Type conversion between objects
+- **DomainEvent**: Events triggered by domain operations
+- **ValidationError**: Validation error representation
+
+### When to Use Domain Module
+
+- Building domain models
+- Defining entities and value objects
+- Creating custom validators
+- Building custom converters
+- Handling domain events
+
+---
+
+## Domain-JPA Module
+
+**Artifact ID**: `tools.dynamia.domain.jpa`
+
+**Purpose**: Provides JPA-based implementations of domain abstractions.
+
+### Key Features
+
+- **BaseEntity**: Abstract base class for JPA entities
+- **Auditable Implementation**: Built-in audit fields
+- **Entity Lifecycle**: Automatic handling of creation/modification dates
+- **Generic Repositories**: Pre-built JPA repositories
+
+### BaseEntity
+
+All JPA entities should extend BaseEntity:
+
+```java
+import tools.dynamia.domain.jpa.BaseEntity;
+
+@Entity
+@Table(name = "contacts")
+public class Contact extends BaseEntity {
+
+ private String name;
+ private String email;
+
+ // Inherits from BaseEntity:
+ // - id (auto-generated)
+ // - creationDate (auto-set)
+ // - createdBy (auto-set)
+ // - modificationDate (auto-set)
+ // - modifiedBy (auto-set)
+}
+```
+
+### Auditable Implementation
+
+Automatic audit trail:
+
+```java
+@Entity
+public class Contact extends BaseEntity {
+
+ @ManyToOne
+ private User createdBy;
+
+ private LocalDateTime creationDate;
+
+ @ManyToOne
+ private User modifiedBy;
+
+ private LocalDateTime modificationDate;
+
+ // Auto-populated before persist/update
+}
+```
+
+### Entity Lifecycle
+
+JPA automatically manages:
+
+```java
+Contact contact = new Contact();
+contact.setName("John");
+
+// Before save: createdBy and creationDate are set
+crudService.save(contact);
+
+// Before update: modifiedBy and modificationDate are updated
+contact.setEmail("john@example.com");
+crudService.save(contact);
+```
+
+### When to Use Domain-JPA Module
+
+- Building JPA entities
+- Using auditable entities
+- Leveraging Spring Data repositories
+- Need for automatic lifecycle management
+
+---
+
+## CRUD Module
+
+**Artifact ID**: `tools.dynamia.crud`
+
+**Purpose**: Provides CRUD (Create, Read, Update, Delete) framework.
+
+### Core Components
+
+#### 1. CrudService
+Main abstraction for data persistence:
+
+```java
+public interface CrudService {
+
+ // Create / Save
+ T save(T entity);
+ void save(Collection entities);
+
+ // Read
+ T find(Class entityClass, Object id);
+ List findAll(Class entityClass);
+ List findAll(Class entityClass, String property, Object value);
+
+ // Update
+ T update(T entity);
+
+ // Delete
+ void delete(Class entityClass, Object id);
+ void delete(T entity);
+ void deleteAll(Class entityClass);
+
+ // Count
+ long count(Class entityClass);
+
+ // Exists
+ boolean exists(Class entityClass, Object id);
+}
+```
+
+#### 2. Using CrudService
+
+```java
+@Service
+public class ContactService {
+
+ private final CrudService crudService;
+
+ public ContactService(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ // Create
+ public Contact createContact(String name, String email) {
+ Contact contact = new Contact();
+ contact.setName(name);
+ contact.setEmail(email);
+ return crudService.save(contact);
+ }
+
+ // Read
+ public Contact getContact(Long id) {
+ return crudService.find(Contact.class, id);
+ }
+
+ // Read all
+ public List getAllContacts() {
+ return crudService.findAll(Contact.class);
+ }
+
+ // Read with filter
+ public List getActiveContacts() {
+ return crudService.findAll(Contact.class, "active", true);
+ }
+
+ // Update
+ public Contact updateContact(Contact contact) {
+ return crudService.update(contact);
+ }
+
+ // Delete
+ public void deleteContact(Long id) {
+ crudService.delete(Contact.class, id);
+ }
+
+ // Count
+ public long getTotalContacts() {
+ return crudService.count(Contact.class);
+ }
+}
+```
+
+#### 3. CrudAction
+Base class for CRUD-related actions:
+
+```java
+@InstallAction
+public class ExportContactsAction extends AbstractCrudAction {
+
+ private final CrudService crudService;
+
+ public ExportContactsAction(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ List contacts = crudService.findAll(Contact.class);
+ // Export logic...
+ }
+}
+```
+
+#### 4. CrudPage
+Declarative CRUD interface:
+
+```java
+// In ModuleProvider
+Module module = new Module("crm", "CRM");
+
+// Add CRUD page with all operations
+CrudPage contactsPage = new CrudPage("contacts", "Contacts", Contact.class);
+module.addPage(contactsPage);
+
+// Customize display settings (inherited from NavigationElement)
+contactsPage.setName("Contacts");
+contactsPage.setIcon("contacts");
+contactsPage.setPosition(1.0);
+
+// Use a custom CrudService by name (optional)
+// CrudPage contactsPage = new CrudPage("contacts", "Contacts", Contact.class, "myCustomCrudService");
+```
+
+### CrudPage Configuration
+
+`CrudPage` extends `AbstractCrudPage` → `RendereablePage` → `Page` → `NavigationElement`.
+The available configuration methods come from the inherited hierarchy:
+
+```java
+public class CrudPage extends AbstractCrudPage {
+
+ // Constructors
+ CrudPage(Class entityClass);
+ CrudPage(String id, String name, Class entityClass);
+ CrudPage(String id, String name, Class entityClass, String crudServiceName);
+
+ // Custom CrudService (optional)
+ void setCrudServiceName(String crudServiceName);
+ String getCrudServiceName();
+
+ // Display settings (from NavigationElement)
+ void setName(String name);
+ void setIcon(String icon);
+ void setDescription(String description);
+ void setPosition(double position);
+ void setVisible(boolean visible);
+
+ // Page actions (from Page — uses PageAction, not Action)
+ Page addAction(PageAction action);
+ void removeAction(PageAction action);
+ List getActions();
+}
+```
+
+### When to Use CRUD Module
+
+- Create CRUD operations for entities
+- Build CRUD pages quickly
+- Handle entity persistence
+- Create data access services
+
+---
+
+## Navigation Module
+
+**Artifact ID**: `tools.dynamia.navigation`
+
+**Purpose**: Manages application navigation, modules, and pages.
+
+### Core Components
+
+#### 1. Module
+Represents a navigational module:
+
+```java
+public class Module extends NavigationElement {
+
+ // Constructors
+ public Module();
+ public Module(String id, String name);
+ public Module(String id, String name, String description);
+
+ // Navigation structure
+ Module addPageGroup(PageGroup group);
+ Module addPageGroup(PageGroup... groups);
+ Module addPage(Page page); // adds to the default group
+ Module addPage(Page... pages);
+
+ // Retrieval
+ List getPageGroups();
+ PageGroup getDefaultPageGroup(); // implicit group for top-level pages
+ PageGroup getPageGroupById(String id);
+ Page getFirstPage();
+ Page getMainPage();
+ void setMainPage(Page page);
+
+ // Search
+ Page findPage(String virtualPath); // e.g. "crm/contacts"
+ Page findPageByPrettyPath(String prettyPath);
+
+ // Iteration
+ void forEachPage(Consumer action);
+
+ // Custom properties
+ Object addProperty(String key, Object value);
+ Object getProperty(String key);
+ Map getProperties();
+ boolean isEmpty();
+
+ // Fluent builder (static factory)
+ static JavaModuleBuilder builder(String name);
+ static JavaModuleBuilder builder(String id, String name);
+ static JavaModuleBuilder builder(String id, String name, String icon, double position);
+
+ // Localization / i18n
+ void setBaseClass(Class baseClass);
+ void addBaseClass(Class baseClass);
+
+ // Reference module
+ static Module getRef(String id);
+}
+```
+
+#### 2. ModuleProvider
+Register modules:
+
+```java
+public interface ModuleProvider {
+ Module getModule();
+}
+
+@Component
+public class ContactModuleProvider implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ Module module = new Module("crm", "CRM");
+ module.setIcon("business");
+
+ PageGroup salesGroup = new PageGroup("sales", "Sales");
+ salesGroup.addPage(new CrudPage("orders", "Orders", Order.class));
+ module.addPageGroup(salesGroup);
+
+ return module;
+ }
+}
+```
+
+#### 3. Page
+Represents a navigable page:
+
+```java
+public class Page extends NavigationElement {
+
+ // Constructors — path is required
+ public Page();
+ public Page(String id, String name, String path);
+ public Page(String id, String name, String path, boolean closeable);
+
+ // Path
+ Page setPath(String path);
+ String getPath();
+ String getVirtualPath(); // e.g. "crm/sales/orders"
+ String getPrettyVirtualPath(); // e.g. "crm/sales/orders" (human-readable)
+
+ // Display (inherited from NavigationElement)
+ void setIcon(String icon);
+ void setDescription(String description);
+ void setPosition(double position);
+ void setVisible(boolean visible);
+
+ // Behaviour
+ Page setClosable(boolean closable);
+ Page setShowAsPopup(boolean popup);
+
+ // Featured / priority
+ Page setFeatured(boolean featured);
+ Page featured();
+ Page featured(int priority);
+ void setPriority(int priority); // default 100
+
+ // Main page marker
+ Page setMain(boolean main);
+ Page main();
+
+ // Actions (uses PageAction, not Action)
+ Page addAction(PageAction action);
+ Page addActions(PageAction action, PageAction... others);
+ void removeAction(PageAction action);
+ List getActions();
+
+ // Lifecycle callbacks
+ void onOpen(Callback callback);
+ void onClose(Callback callback);
+ void onUnload(Callback callback);
+
+ // Hierarchy
+ String getFullName();
+ PageGroup getPageGroup();
+}
+```
+
+#### 4. ModuleContainer
+Central registry for modules:
+
+```java
+@Autowired
+private ModuleContainer moduleContainer;
+
+public void workWithModules() {
+ // Get all modules
+ Collection modules = moduleContainer.getModules();
+
+ // Find specific module
+ Module crm = moduleContainer.getModuleById("crm");
+
+ // Find page
+ Page contactsPage = moduleContainer.findPage("/crm/contacts");
+
+ // Get navigation tree
+ List topModules = moduleContainer.getModules();
+}
+```
+
+### Navigation Hierarchy
+
+```
+Module (e.g., "CRM")
+├── PageGroup (e.g., "Sales")
+│ ├── Page (e.g., "Orders")
+│ └── Page (e.g., "Invoices")
+├── PageGroup (e.g., "Customers")
+│ ├── Page (e.g., "Contacts")
+│ └── Page (e.g., "Companies")
+└── Page (e.g., "Dashboard")
+```
+
+### When to Use Navigation Module
+
+- Register modules and pages
+- Build navigation menu
+- Create module references
+- Organize application features
+
+---
+
+## Actions Module
+
+**Artifact ID**: `tools.dynamia.actions`
+
+**Purpose**: Provides action framework for handling user interactions.
+
+### Core Components
+
+#### 1. Action Interface
+Represents an action:
+
+```java
+public interface Action {
+
+ String getName();
+ String getDescription();
+ String getIcon();
+
+ boolean isVisible();
+ boolean isEnabled();
+
+ void actionPerformed(ActionEvent evt);
+}
+```
+
+#### 2. AbstractAction
+Base class for actions:
+
+```java
+@InstallAction
+public class SendEmailAction extends AbstractAction {
+
+ private final EmailService emailService;
+
+ public SendEmailAction(EmailService emailService) {
+ super("Send Email", "sendEmail");
+ this.emailService = emailService;
+ setIcon("email");
+ }
+
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ // Action implementation
+ emailService.send(...);
+ }
+}
+```
+
+#### 3. CrudAction
+Action for CRUD operations:
+
+```java
+@InstallAction
+public class ApproveContactAction extends AbstractCrudAction {
+
+ private final ContactService contactService;
+
+ public ApproveContactAction(ContactService contactService) {
+ super("Approve", "approve");
+ this.contactService = contactService;
+ }
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ Contact contact = (Contact) evt.getEntity();
+ contactService.approveContact(contact);
+ }
+}
+```
+
+#### 4. InstallAction Annotation
+Register actions:
+
+```java
+@InstallAction
+public class MyAction extends AbstractAction {
+ // Automatically registered as Spring bean
+ // Discovered and registered in action registry
+}
+
+// Usage in UI
+@Autowired
+private ActionRegistry actionRegistry;
+
+public void useAction() {
+ Action action = actionRegistry.findAction("myAction");
+ action.actionPerformed(new ActionEvent(this));
+}
+```
+
+### Action Types
+
+| Type | Purpose | Example |
+|------|---------|---------|
+| **Generic Action** | General operations | Print, Export, Archive |
+| **CRUD Action** | Entity operations | Approve, Activate, Deactivate |
+| **Custom Action** | Domain-specific | Calculate, SendEmail, GenerateReport |
+
+### When to Use Actions Module
+
+- Implement reusable operations
+- Create custom button actions
+- Encapsulate business logic
+- Build menu items and toolbar actions
+
+---
+
+## Integration Module
+
+**Artifact ID**: `tools.dynamia.integration`
+
+**Purpose**: Provides Spring Boot integration and auto-configuration.
+
+### Key Features
+
+- **EnableDynamiaTools Annotation**: Main annotation to enable platform
+- **Auto-configuration**: Automatic bean registration and initialization
+- **Property Configuration**: Configuration via application.properties
+- **Customizers**: Extension points for customization
+
+### EnableDynamiaTools
+
+```java
+@SpringBootApplication
+@EnableDynamiaTools
+public class MyApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(MyApplication.class, args);
+ }
+}
+```
+
+What @EnableDynamiaTools does:
+1. Scans for ModuleProviders
+2. Initializes metadata engine
+3. Registers CrudService beans
+4. Sets up navigation system
+5. Initializes validators and converters
+6. Configures view renderers
+
+### Configuration Properties
+
+```properties
+# Application name and title
+dynamia.application.name=My Application
+dynamia.application.title=My Enterprise App
+
+# Navigation settings
+dynamia.navigation.auto-discover-modules=true
+dynamia.navigation.default-module=home
+
+# CRUD settings
+dynamia.crud.default-page-size=20
+dynamia.crud.enable-cache=true
+
+# View rendering
+dynamia.views.theme=dynamical
+dynamia.views.enable-descriptors=true
+
+# Security
+dynamia.security.enabled=true
+```
+
+### When to Use Integration Module
+
+- Configure DynamiaTools startup
+- Set application properties
+- Enable/disable features
+- Customize auto-configuration
+
+---
+
+## Web Module
+
+**Artifact ID**: `tools.dynamia.web`
+
+**Purpose**: Provides web utilities and HTTP support.
+
+### Key Features
+
+- **REST Support**: REST endpoint utilities
+- **HTTP Utilities**: Request/response helpers
+- **PWA Support**: Progressive Web App features
+- **Web Configuration**: Web layer configuration
+
+### REST Utilities
+
+```java
+@RestController
+@RequestMapping("/api/contacts")
+public class ContactRestController {
+
+ private final CrudService crudService;
+
+ @GetMapping
+ public ResponseEntity> getAll() {
+ List contacts = crudService.findAll(Contact.class);
+ return ResponseEntity.ok(contacts);
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getById(@PathVariable Long id) {
+ Contact contact = crudService.find(Contact.class, id);
+ return contact != null ? ResponseEntity.ok(contact) : ResponseEntity.notFound().build();
+ }
+
+ @PostMapping
+ public ResponseEntity create(@RequestBody Contact contact) {
+ Contact created = crudService.save(contact);
+ return ResponseEntity.status(201).body(created);
+ }
+}
+```
+
+### PWA Configuration
+
+```java
+@Configuration
+public class PwaConfig {
+
+ @Bean
+ public PwaManifest pwaManifest() {
+ PwaManifest manifest = new PwaManifest();
+ manifest.setName("My Application");
+ manifest.setShortName("MyApp");
+ manifest.setDisplay(PwaManifest.Display.STANDALONE);
+ return manifest;
+ }
+}
+```
+
+### When to Use Web Module
+
+- Build REST APIs
+- Configure web layer
+- Implement PWA features
+- Handle HTTP requests/responses
+
+---
+
+## Reports Module
+
+**Artifact ID**: `tools.dynamia.reports`
+
+**Purpose**: Provides reporting and data analysis framework.
+
+### Key Features
+
+- **Query Builders**: JPQL and native SQL queries
+- **Report Templates**: Pre-built report types
+- **Export Formats**: CSV, Excel, PDF export
+- **Report Actions**: Built-in report actions
+- **Chart Integration**: Visual data representation
+
+### Report Definition
+
+```java
+@Service
+public class ContactReportService {
+
+ private final ReportBuilder reportBuilder;
+
+ public Report generateContactReport() {
+ Report report = reportBuilder.create("contacts-report")
+ .setTitle("Contacts Report")
+ .setQuery("SELECT c FROM Contact c WHERE c.active = true")
+ .addColumn("name", "Name", String.class)
+ .addColumn("email", "Email", String.class)
+ .addColumn("company.name", "Company", String.class)
+ .setExportFormat(ExportFormat.EXCEL)
+ .build();
+
+ return report;
+ }
+}
+```
+
+### Report Rendering
+
+```java
+@Autowired
+private ReportRenderer reportRenderer;
+
+public void renderReport(Report report) {
+ reportRenderer.render(report, response.getOutputStream());
+}
+```
+
+### When to Use Reports Module
+
+- Generate reports from entities
+- Export data to various formats
+- Create business analytics
+- Generate visualizations
+
+---
+
+## Templates Module
+
+**Artifact ID**: `tools.dynamia.templates`
+
+**Purpose**: Provides template rendering system.
+
+### Template Features
+
+- **Template Engine**: Freemarker-based templating
+- **Template Variables**: Pass data to templates
+- **Template Inheritance**: Template composition
+- **Custom Directives**: Create custom template functions
+
+### Template Usage
+
+```java
+@Service
+public class EmailTemplateService {
+
+ private final TemplateEngine templateEngine;
+
+ public String renderEmailTemplate(String templateName, Map data) {
+ return templateEngine.render(templateName, data);
+ }
+}
+
+// Usage
+Map data = new HashMap<>();
+data.put("firstName", "John");
+data.put("lastName", "Doe");
+
+String emailBody = emailTemplateService.renderEmailTemplate("welcome-email", data);
+```
+
+### Template File Example
+
+```freemarker
+
+Welcome ${firstName} ${lastName}!
+Thank you for registering.
+```
+
+### When to Use Templates Module
+
+- Generate email content
+- Create dynamic documents
+- Build report templates
+- Generate dynamic HTML
+
+---
+
+## Viewers Module
+
+**Artifact ID**: `tools.dynamia.viewers`
+
+**Purpose**: Provides view rendering system and view renderers.
+
+### Core Components
+
+#### 1. ViewRenderer
+Interface for rendering views:
+
+```java
+public interface ViewRenderer {
+
+ void render(ViewDescriptor view, Map data, HttpServletResponse response);
+
+ String getViewType();
+
+ boolean supports(ViewDescriptor view);
+}
+```
+
+#### 2. View Descriptors
+YAML-based view definitions:
+
+```yaml
+# ContactForm.yml
+view: form
+beanClass: com.example.Contact
+fields:
+ name:
+ label: Contact Name
+ required: true
+ email:
+ label: Email Address
+ required: true
+ phone:
+ label: Phone Number
+ company:
+ reference: true
+ referencedClass: com.example.Company
+```
+
+#### 3. ViewCustomizer
+Customize views before rendering:
+
+```java
+@Component
+public class ContactViewCustomizer implements ViewCustomizer {
+
+ @Override
+ public void customize(ViewDescriptor view, Map metadata) {
+ if (view.getBeanClass() == Contact.class) {
+ // Hide sensitive fields
+ view.getFields().get("internalNotes").setVisible(false);
+ }
+ }
+
+ @Override
+ public Class> getCustomizedClass() {
+ return Contact.class;
+ }
+}
+```
+
+#### 4. ZKViewRenderer
+Default ZK-based renderer:
+
+```java
+@Component
+public class ZKViewRenderer implements ViewRenderer {
+
+ @Override
+ public void render(ViewDescriptor view, Map data, HttpServletResponse response) {
+ // Convert view descriptor to ZK components
+ // Generate HTML/JavaScript
+ }
+
+ @Override
+ public String getViewType() {
+ return "zk";
+ }
+}
+```
+
+### Supported View Types
+
+| Type | Purpose | Example |
+|------|---------|---------|
+| **form** | Data entry forms | Edit contact form |
+| **table** | Data display tables | Contact list table |
+| **tree** | Hierarchical data | Category tree |
+| **report** | Data reports | Sales report |
+| **dashboard** | Multiple widgets | Executive dashboard |
+
+### Creating Custom Renderer
+
+```java
+@Component
+public class CustomViewRenderer implements ViewRenderer {
+
+ @Override
+ public void render(ViewDescriptor view, Map data, HttpServletResponse response) {
+ // Custom rendering logic
+ // Could render React, Vue, Angular, etc.
+ }
+
+ @Override
+ public String getViewType() {
+ return "custom";
+ }
+
+ @Override
+ public boolean supports(ViewDescriptor view) {
+ return "custom".equals(view.getType());
+ }
+}
+```
+
+### When to Use Viewers Module
+
+- Render views from descriptors
+- Create custom view renderers
+- Customize existing views
+- Build alternative UI frameworks
+
+---
+
+## App Module
+
+**Artifact ID**: `tools.dynamia.app`
+
+**Purpose**: Provides application bootstrap and metadata exposure.
+
+### Key Components
+
+#### 1. ApplicationMetadata
+Exposes application metadata:
+
+```java
+@RestController
+@RequestMapping("/api/metadata")
+public class ApplicationMetadataController {
+
+ @GetMapping("/modules")
+ public ResponseEntity> getModules() {
+ // Returns all registered modules
+ }
+
+ @GetMapping("/entities/{className}")
+ public ResponseEntity getEntityMetadata(@PathVariable String className) {
+ // Returns entity metadata: fields, relationships, constraints
+ }
+
+ @GetMapping("/views/{beanClass}")
+ public ResponseEntity getViewDescriptor(@PathVariable String beanClass) {
+ // Returns view descriptor for entity
+ }
+}
+```
+
+#### 2. Application Bootstrap
+Automatic application startup:
+
+```java
+@SpringBootApplication
+@EnableDynamiaTools
+public class MyApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MyApplication.class, args);
+ // Automatically:
+ // - Scans for @Component classes
+ // - Registers ModuleProviders
+ // - Initializes metadata engine
+ // - Sets up navigation
+ // - Configures security (if enabled)
+ }
+}
+```
+
+### When to Use App Module
+
+- Bootstrap DynamiaTools applications
+- Expose metadata to frontend
+- Configure application startup
+- Access application information
+
+---
+
+## Module Dependencies
+
+Here's the dependency hierarchy of core modules:
+
+```
+Presentation Layer
+ └── Viewers Module
+ └── Templates Module
+ └── Web Module
+
+Application Layer
+ ├── CRUD Module
+ │ └── Domain Module
+ ├── Actions Module
+ │ └── Domain Module
+ ├── Navigation Module
+ │ └── Domain Module
+ └── Integration Module
+ ├── CRUD Module
+ ├── Navigation Module
+ └── App Module
+
+Business Logic Layer
+ └── (Your services)
+
+Data Access Layer
+ ├── Domain-JPA Module
+ │ └── Domain Module
+ └── CrudService Implementation
+
+Infrastructure Layer
+ ├── Commons Module
+ ├── Integration Module
+ └── (Database, etc.)
+```
+
+### Minimal Dependencies
+
+For a minimal DynamiaTools application:
+
+```xml
+
+
+ tools.dynamia
+ tools.dynamia.app
+ 26.3.2
+
+
+
+
+ tools.dynamia
+ tools.dynamia.domain.jpa
+ 26.3.2
+
+
+
+
+ tools.dynamia
+ tools.dynamia.zk
+ 26.3.2
+
+```
+
+This brings in all core modules as transitive dependencies.
+
+---
+
+## Module Interdependencies
+
+| Module | Depends On |
+|--------|-----------|
+| **commons** | (no dependencies) |
+| **domain** | commons |
+| **domain-jpa** | domain |
+| **crud** | domain, domain-jpa |
+| **navigation** | domain, commons |
+| **actions** | domain, commons |
+| **integration** | all core modules |
+| **web** | commons, domain |
+| **reports** | domain, crud, commons |
+| **templates** | commons |
+| **viewers** | domain, templates, commons |
+| **app** | all core modules |
+
+---
+
+## Summary
+
+Each core module provides specific functionality:
+
+- **commons**: Utilities and helpers
+- **domain**: Domain abstractions
+- **domain-jpa**: JPA implementations
+- **crud**: Data operations
+- **navigation**: Application structure
+- **actions**: User interactions
+- **integration**: Spring Boot setup
+- **web**: HTTP and REST
+- **reports**: Reporting and analytics
+- **templates**: Template rendering
+- **viewers**: UI rendering
+- **app**: Application bootstrap
+
+Understanding these modules is essential for building effective DynamiaTools applications.
+
+---
+
+Next: Read [Development Patterns](./DEVELOPMENT_PATTERNS.md) to learn best practices and patterns.
+
diff --git a/docs/backend/DEVELOPMENT_PATTERNS.md b/docs/backend/DEVELOPMENT_PATTERNS.md
new file mode 100644
index 00000000..c5284e32
--- /dev/null
+++ b/docs/backend/DEVELOPMENT_PATTERNS.md
@@ -0,0 +1,1288 @@
+# Development Patterns & Best Practices
+
+This document provides guidance on common patterns, best practices, and anti-patterns when developing with DynamiaTools.
+
+## Table of Contents
+
+1. [Entity Design Patterns](#entity-design-patterns)
+2. [Service Layer Patterns](#service-layer-patterns)
+3. [Module Provider Pattern](#module-provider-pattern)
+4. [Action Patterns](#action-patterns)
+5. [Validator Pattern](#validator-pattern)
+6. [Customizer Pattern](#customizer-pattern)
+7. [View Descriptor Patterns](#view-descriptor-patterns)
+8. [Error Handling](#error-handling)
+9. [Performance Patterns](#performance-patterns)
+10. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
+
+---
+
+## Entity Design Patterns
+
+### Pattern 1: Proper Entity Inheritance
+
+**✅ CORRECT**: Extend BaseEntity with JPA annotations
+
+```java
+@Entity
+@Table(name = "contacts")
+public class Contact extends BaseEntity {
+
+ private String name;
+ private String email;
+
+ @ManyToOne
+ @JoinColumn(name = "company_id")
+ private Company company;
+
+ @Temporal(TemporalType.TIMESTAMP)
+ private LocalDateTime createdAt;
+
+ // Business methods
+ public boolean isValid() {
+ return name != null && email != null;
+ }
+
+ // Getters and setters
+}
+```
+
+**❌ WRONG**: Manual id management, no inheritance
+
+```java
+@Entity
+public class Contact {
+
+ @Id
+ @GeneratedValue
+ private Long id;
+
+ private String name;
+ // Missing audit fields
+ // No business logic
+}
+```
+
+### Pattern 2: Relationships
+
+**✅ CORRECT**: Proper JPA relationships with cascade settings
+
+```java
+@Entity
+public class Company extends BaseEntity {
+
+ private String name;
+
+ @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List contacts = new ArrayList<>();
+
+ // Add/remove helpers
+ public void addContact(Contact contact) {
+ contacts.add(contact);
+ contact.setCompany(this);
+ }
+
+ public void removeContact(Contact contact) {
+ contacts.remove(contact);
+ contact.setCompany(null);
+ }
+}
+```
+
+**❌ WRONG**: No cascade settings, missing collection initialization
+
+```java
+@Entity
+public class Company extends BaseEntity {
+
+ private String name;
+
+ @OneToMany
+ private List contacts; // Not initialized, no cascade
+}
+```
+
+### Pattern 3: Enums
+
+**✅ CORRECT**: Using @Enumerated for proper persistence
+
+```java
+@Entity
+public class Contact extends BaseEntity {
+
+ @Enumerated(EnumType.STRING)
+ private ContactStatus status;
+
+ @Enumerated(EnumType.STRING)
+ private ContactType type;
+}
+
+public enum ContactStatus {
+ ACTIVE,
+ INACTIVE,
+ ARCHIVED
+}
+
+public enum ContactType {
+ PERSON,
+ COMPANY,
+ GOVERNMENT
+}
+```
+
+**❌ WRONG**: Storing enums as integers or strings without @Enumerated
+
+```java
+@Entity
+public class Contact extends BaseEntity {
+
+ private String status; // Fragile to typos
+ private Integer type; // Unclear what numbers mean
+}
+```
+
+### Pattern 4: Embedded Objects
+
+**✅ CORRECT**: Using @Embeddable for value objects
+
+```java
+@Embeddable
+public class Address {
+
+ private String street;
+ private String city;
+ private String state;
+ private String zipCode;
+
+ public String getFormattedAddress() {
+ return street + ", " + city + ", " + state + " " + zipCode;
+ }
+}
+
+@Entity
+public class Contact extends BaseEntity {
+
+ private String name;
+
+ @Embedded
+ @AttributeOverrides({
+ @AttributeOverride(name = "street", column = @Column(name = "billing_street")),
+ @AttributeOverride(name = "city", column = @Column(name = "billing_city"))
+ })
+ private Address billingAddress;
+
+ @Embedded
+ private Address shippingAddress;
+}
+```
+
+**❌ WRONG**: Repeated fields instead of embedded objects
+
+```java
+@Entity
+public class Contact extends BaseEntity {
+
+ private String billingStreet;
+ private String billingCity;
+ private String shippingStreet;
+ private String shippingCity;
+ // Duplicate logic
+}
+```
+
+---
+
+## Service Layer Patterns
+
+### Pattern 1: Dependency Injection
+
+**✅ CORRECT**: Constructor injection for immutability
+
+```java
+@Service
+public class ContactService {
+
+ private final CrudService crudService;
+ private final EmailService emailService;
+ private final ContactValidator validator;
+
+ public ContactService(CrudService crudService,
+ EmailService emailService,
+ ContactValidator validator) {
+ this.crudService = crudService;
+ this.emailService = emailService;
+ this.validator = validator;
+ }
+
+ public Contact createContact(String name, String email) {
+ Contact contact = new Contact();
+ contact.setName(name);
+ contact.setEmail(email);
+ return crudService.save(contact);
+ }
+}
+```
+
+**❌ WRONG**: Field injection or service locator pattern
+
+```java
+@Service
+public class ContactService {
+
+ @Autowired // Fragile, hard to test
+ private CrudService crudService;
+
+ // Or even worse:
+ private static ContactService instance;
+
+ public static ContactService getInstance() {
+ return instance;
+ }
+}
+```
+
+### Pattern 2: Transaction Management
+
+**✅ CORRECT**: Using @Transactional annotation
+
+```java
+@Service
+public class OrderService {
+
+ private final CrudService crudService;
+ private final EmailService emailService;
+
+ @Transactional
+ public Order createOrder(Order order, List lines) {
+ // All or nothing - if email fails, order is rolled back
+ Order saved = crudService.save(order);
+
+ for (OrderLine line : lines) {
+ line.setOrder(saved);
+ crudService.save(line);
+ }
+
+ emailService.sendOrderConfirmation(saved);
+
+ return saved;
+ }
+
+ @Transactional(readOnly = true)
+ public Order getOrderWithLines(Long orderId) {
+ return crudService.find(Order.class, orderId);
+ }
+}
+```
+
+**❌ WRONG**: No transaction management, manual transaction handling
+
+```java
+@Service
+public class OrderService {
+
+ public Order createOrder(Order order, List lines) {
+ // If email fails, order is saved but transaction inconsistent
+ Order saved = crudService.save(order);
+ // ... more logic
+ emailService.sendOrderConfirmation(saved); // Could fail
+ return saved;
+ }
+}
+```
+
+### Pattern 3: Business Logic in Services
+
+**✅ CORRECT**: Services contain business logic, not entities
+
+```java
+@Service
+public class ContactApprovalService {
+
+ private final CrudService crudService;
+ private final NotificationService notificationService;
+
+ @Transactional
+ public Contact approveContact(Long contactId) {
+ Contact contact = crudService.find(Contact.class, contactId);
+
+ if (!contact.isPending()) {
+ throw new IllegalStateException("Contact is not in pending status");
+ }
+
+ contact.setStatus(ContactStatus.APPROVED);
+ contact.setApprovedDate(LocalDateTime.now());
+
+ Contact saved = crudService.save(contact);
+
+ // Notify after approval
+ notificationService.sendApprovalNotification(saved);
+
+ return saved;
+ }
+}
+```
+
+**❌ WRONG**: Business logic in entity or in action
+
+```java
+@Entity
+public class Contact extends BaseEntity {
+
+ public void approve(NotificationService notificationService) {
+ // Bad: Entity should not call services
+ this.status = ContactStatus.APPROVED;
+ notificationService.send(...);
+ }
+}
+```
+
+---
+
+## Module Provider Pattern
+
+### Pattern 1: Basic Module Provider
+
+**✅ CORRECT**: Implement ModuleProvider as Spring component
+
+```java
+@Component
+public class CrmModuleProvider implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ Module module = new Module("crm", "CRM");
+ module.setIcon("business");
+ module.setDescription("Customer Relationship Management");
+ module.setPosition(10);
+
+ // Contacts page group
+ PageGroup contactGroup = new PageGroup("contacts-group", "Contacts");
+ contactGroup.addPage(new CrudPage("contacts", "All Contacts", Contact.class));
+ module.addPageGroup(contactGroup);
+
+ // Companies page group
+ PageGroup companyGroup = new PageGroup("companies-group", "Companies");
+ companyGroup.addPage(new CrudPage("companies", "All Companies", Company.class));
+ module.addPageGroup(companyGroup);
+
+ return module;
+ }
+}
+```
+
+**❌ WRONG**: Module provider not registered as component
+
+```java
+public class CrmModuleProvider implements ModuleProvider {
+ // Not a Spring component - won't be discovered!
+
+ @Override
+ public Module getModule() {
+ // ...
+ }
+}
+```
+
+### Pattern 2: Advanced Module Configuration
+
+**✅ CORRECT**: Complex module setup with dependencies
+
+```java
+@Component
+public class AdvancedModuleProvider implements ModuleProvider {
+
+ private final CrudService crudService;
+ private final SecurityService securityService;
+
+ public AdvancedModuleProvider(CrudService crudService,
+ SecurityService securityService) {
+ this.crudService = crudService;
+ this.securityService = securityService;
+ }
+
+ @Override
+ public Module getModule() {
+ Module module = new Module("advanced", "Advanced");
+
+ // Only add pages if user has permission
+ if (securityService.hasPermission("advanced:read")) {
+
+ // Get data to calculate position
+ long contactCount = crudService.count(Contact.class);
+
+ PageGroup group = new PageGroup("main", "Main");
+
+ // Add dashboard
+ if (contactCount > 0) {
+ group.addPage(new Page("dashboard", "Dashboard"));
+ }
+
+ module.addPageGroup(group);
+ }
+
+ return module;
+ }
+}
+```
+
+---
+
+## Action Patterns
+
+### Pattern 1: Simple Action
+
+**✅ CORRECT**: Basic action implementation
+
+```java
+@InstallAction
+public class ExportContactsAction extends AbstractAction {
+
+ private final CrudService crudService;
+ private final ExportService exportService;
+
+ public ExportContactsAction(CrudService crudService, ExportService exportService) {
+ super("Export Contacts", "exportContacts");
+ this.crudService = crudService;
+ this.exportService = exportService;
+ setIcon("download");
+ setDescription("Export all contacts to Excel");
+ }
+
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ List contacts = crudService.findAll(Contact.class);
+ exportService.exportToExcel(contacts);
+ }
+}
+```
+
+**❌ WRONG**: Action with hardcoded logic, not reusable
+
+```java
+@InstallAction
+public class ContactAction extends AbstractAction {
+
+ public ContactAction() {
+ super("Contact Action", "contactAction");
+ }
+
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ // Hardcoded queries
+ List results = entityManager.createQuery("SELECT c FROM Contact c").getResultList();
+ // Direct file I/O
+ // No separation of concerns
+ }
+}
+```
+
+### Pattern 2: CRUD Action
+
+**✅ CORRECT**: Entity-specific action
+
+```java
+@InstallAction
+public class ActivateContactAction extends AbstractCrudAction {
+
+ private final ContactService contactService;
+
+ public ActivateContactAction(ContactService contactService) {
+ super("Activate", "activate");
+ this.contactService = contactService;
+ setIcon("check");
+ }
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ Contact contact = (Contact) evt.getEntity();
+
+ if (contact.getStatus() != ContactStatus.INACTIVE) {
+ showMessage("Contact is not inactive");
+ return;
+ }
+
+ contactService.activateContact(contact);
+ showMessage("Contact activated successfully");
+ }
+}
+```
+
+**❌ WRONG**: Action without entity validation
+
+```java
+@InstallAction
+public class ActivateAction extends AbstractCrudAction {
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ Contact contact = (Contact) evt.getEntity();
+ contact.setActive(true); // No validation, no service call
+ }
+}
+```
+
+### Pattern 3: Conditional Actions
+
+**✅ CORRECT**: Actions that check conditions
+
+```java
+@InstallAction
+public class ApproveContactAction extends AbstractCrudAction {
+
+ private final ContactService contactService;
+ private final SecurityService securityService;
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ Contact contact = (Contact) evt.getEntity();
+ contactService.approveContact(contact);
+ }
+
+ @Override
+ public boolean isEnabled(Object entity) {
+ if (!(entity instanceof Contact)) {
+ return false;
+ }
+
+ Contact contact = (Contact) entity;
+
+ // Only enabled for pending contacts
+ return contact.getStatus() == ContactStatus.PENDING;
+ }
+
+ @Override
+ public boolean isVisible() {
+ // Only show to managers
+ return securityService.hasRole("MANAGER");
+ }
+}
+```
+
+---
+
+## Validator Pattern
+
+### Pattern 1: Field Validator
+
+**✅ CORRECT**: Validate individual fields
+
+```java
+@InstallValidator
+public class ContactEmailValidator implements Validator {
+
+ private final CrudService crudService;
+
+ public ContactEmailValidator(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ @Override
+ public List validate(Contact contact) {
+ List errors = new ArrayList<>();
+
+ // Email format validation
+ if (contact.getEmail() != null && !isValidEmail(contact.getEmail())) {
+ errors.add(new ValidationError("email", "Invalid email format"));
+ }
+
+ // Uniqueness validation (database check)
+ if (contact.getEmail() != null && contact.getId() == null) {
+ Contact existing = crudService.findFirst(Contact.class,
+ "email", contact.getEmail());
+ if (existing != null) {
+ errors.add(new ValidationError("email", "Email already registered"));
+ }
+ }
+
+ return errors;
+ }
+
+ @Override
+ public Class getValidatedClass() {
+ return Contact.class;
+ }
+
+ private boolean isValidEmail(String email) {
+ return email.matches("[A-Za-z0-9+_.-]+@(.+)$");
+ }
+}
+```
+
+**❌ WRONG**: Validation in entity
+
+```java
+@Entity
+public class Contact extends BaseEntity {
+
+ private String email;
+
+ @PrePersist
+ public void validate() {
+ if (email == null) {
+ throw new RuntimeException("Email is required"); // Bad: exceptions in lifecycle
+ }
+ }
+}
+```
+
+### Pattern 2: Complex Validation
+
+**✅ CORRECT**: Multi-field cross validation
+
+```java
+@InstallValidator
+public class OrderValidator implements Validator {
+
+ @Override
+ public List validate(Order order) {
+ List errors = new ArrayList<>();
+
+ // Check dates
+ if (order.getShipDate() != null && order.getOrderDate() != null) {
+ if (order.getShipDate().isBefore(order.getOrderDate())) {
+ errors.add(new ValidationError("shipDate",
+ "Ship date must be after order date"));
+ }
+ }
+
+ // Check total amount
+ if (order.getTotal() != null && order.getTotal().signum() <= 0) {
+ errors.add(new ValidationError("total", "Total must be positive"));
+ }
+
+ // Check lines
+ if (order.getLines() == null || order.getLines().isEmpty()) {
+ errors.add(new ValidationError("lines", "Order must have at least one line"));
+ }
+
+ return errors;
+ }
+
+ @Override
+ public Class getValidatedClass() {
+ return Order.class;
+ }
+}
+```
+
+---
+
+## Customizer Pattern
+
+### Pattern 1: View Customizer
+
+**✅ CORRECT**: Modify view descriptors before rendering
+
+```java
+@Component
+public class ContactFormCustomizer implements ViewCustomizer {
+
+ private final SecurityService securityService;
+
+ public ContactFormCustomizer(SecurityService securityService) {
+ this.securityService = securityService;
+ }
+
+ @Override
+ public void customize(ViewDescriptor view, Map metadata) {
+ if (view.getBeanClass() != Contact.class) {
+ return;
+ }
+
+ // Hide fields from non-admins
+ if (!securityService.hasRole("ADMIN")) {
+ view.getFields().get("internalNotes").setVisible(false);
+ view.getFields().get("creditLimit").setVisible(false);
+ }
+
+ // Make email read-only for existing records
+ if (metadata.get("isNew") == false) {
+ view.getFields().get("email").setReadOnly(true);
+ }
+
+ // Add computed field
+ FieldDescriptor statusField = new FieldDescriptor("statusText");
+ statusField.setLabel("Status");
+ statusField.setReadOnly(true);
+ view.addField(statusField);
+ }
+
+ @Override
+ public Class> getCustomizedClass() {
+ return Contact.class;
+ }
+}
+```
+
+### Pattern 2: Multiple Customizers
+
+**✅ CORRECT**: Chain multiple customizers
+
+```java
+@Component
+public class AdminViewCustomizer implements ViewCustomizer {
+
+ @Override
+ public void customize(ViewDescriptor view, Map metadata) {
+ // Admin-specific customization
+ }
+}
+
+@Component
+public class AuditViewCustomizer implements ViewCustomizer {
+
+ @Override
+ public void customize(ViewDescriptor view, Map metadata) {
+ // Add audit fields
+ view.addField(createReadOnlyField("createdBy"));
+ view.addField(createReadOnlyField("createdDate"));
+ view.addField(createReadOnlyField("modifiedBy"));
+ view.addField(createReadOnlyField("modifiedDate"));
+ }
+}
+```
+
+---
+
+## View Descriptor Patterns
+
+### Pattern 1: CRUD View Descriptor
+
+**✅ CORRECT**: Well-structured view descriptor
+
+```yaml
+# ContactCrud.yml
+view: crud
+entity: com.example.Contact
+title: Contact Management
+description: Manage customer contacts
+
+pages:
+ # List page
+ list:
+ view: table
+ fields:
+ - name
+ - email
+ - company.name
+ - status
+ actions:
+ - edit
+ - delete
+ - export
+
+ # Form page (create/edit)
+ form:
+ view: form
+ title: Contact Details
+ fields:
+ name:
+ label: Contact Name
+ required: true
+ minLength: 2
+ maxLength: 100
+ email:
+ label: Email Address
+ required: true
+ pattern: '^[A-Za-z0-9+_.-]+@(.+)$'
+ phone:
+ label: Phone Number
+ company:
+ reference: true
+ referencedEntity: com.example.Company
+ status:
+ label: Status
+ type: enum
+ enumClass: com.example.ContactStatus
+```
+
+### Pattern 2: Customized Field Descriptors
+
+**✅ CORRECT**: Field-level configuration
+
+```yaml
+# DetailedContact.yml
+view: form
+entity: com.example.Contact
+
+fields:
+ name:
+ label: Full Name
+ required: true
+ placeholder: Enter contact name
+ help: Full legal name of the contact
+ className: field-required
+
+ email:
+ label: Email
+ type: email
+ required: true
+ validator: EmailValidator
+
+ phone:
+ label: Phone Number
+ mask: '(999) 999-9999'
+ placeholder: (123) 456-7890
+
+ status:
+ label: Current Status
+ type: select
+ readOnly: true
+ options:
+ - { value: ACTIVE, label: Active }
+ - { value: INACTIVE, label: Inactive }
+ - { value: ARCHIVED, label: Archived }
+
+ notes:
+ label: Internal Notes
+ type: textarea
+ rows: 5
+ visible: ${user.hasRole('ADMIN')}
+```
+
+---
+
+## Error Handling
+
+### Pattern 1: Service Exception Handling
+
+**✅ CORRECT**: Custom exceptions for domain errors
+
+```java
+@Service
+public class ContactService {
+
+ @Transactional
+ public Contact approveContact(Long contactId) throws ContactNotFoundException,
+ InvalidContactStatusException {
+ Contact contact = crudService.find(Contact.class, contactId);
+
+ if (contact == null) {
+ throw new ContactNotFoundException("Contact not found: " + contactId);
+ }
+
+ if (contact.getStatus() != ContactStatus.PENDING) {
+ throw new InvalidContactStatusException(
+ "Cannot approve contact with status: " + contact.getStatus());
+ }
+
+ contact.setStatus(ContactStatus.APPROVED);
+ return crudService.save(contact);
+ }
+}
+
+// Custom exceptions
+public class ContactNotFoundException extends RuntimeException {
+ public ContactNotFoundException(String message) {
+ super(message);
+ }
+}
+
+public class InvalidContactStatusException extends RuntimeException {
+ public InvalidContactStatusException(String message) {
+ super(message);
+ }
+}
+```
+
+**❌ WRONG**: Generic exceptions or silent failures
+
+```java
+public Contact approveContact(Long contactId) {
+ Contact contact = crudService.find(Contact.class, contactId);
+
+ if (contact == null) {
+ return null; // Silent failure
+ }
+
+ if (contact.getStatus() != ContactStatus.PENDING) {
+ throw new RuntimeException("Invalid status"); // Too generic
+ }
+
+ // ...
+}
+```
+
+### Pattern 2: Controller Exception Handling
+
+**✅ CORRECT**: Global exception handler
+
+```java
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(ContactNotFoundException.class)
+ public ResponseEntity handleContactNotFound(
+ ContactNotFoundException ex) {
+ ErrorResponse error = new ErrorResponse("CONTACT_NOT_FOUND", ex.getMessage());
+ return ResponseEntity.status(404).body(error);
+ }
+
+ @ExceptionHandler(InvalidContactStatusException.class)
+ public ResponseEntity handleInvalidStatus(
+ InvalidContactStatusException ex) {
+ ErrorResponse error = new ErrorResponse("INVALID_STATUS", ex.getMessage());
+ return ResponseEntity.status(400).body(error);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGenericException(Exception ex) {
+ ErrorResponse error = new ErrorResponse("INTERNAL_ERROR",
+ "An unexpected error occurred");
+ return ResponseEntity.status(500).body(error);
+ }
+}
+
+public record ErrorResponse(String code, String message) {}
+```
+
+---
+
+## Performance Patterns
+
+### Pattern 1: Lazy Loading
+
+**✅ CORRECT**: Proper lazy loading configuration
+
+```java
+@Entity
+public class Contact extends BaseEntity {
+
+ private String name;
+
+ // Many-to-One is EAGER by default (ok)
+ @ManyToOne
+ private Company company;
+
+ // One-to-Many should be LAZY (default)
+ @OneToMany(mappedBy = "contact", fetch = FetchType.LAZY)
+ private List notes = new ArrayList<>();
+
+ // Large content field should be lazy loaded
+ @Lob
+ @Basic(fetch = FetchType.LAZY)
+ private byte[] attachmentContent;
+}
+```
+
+### Pattern 2: Eager Loading When Needed
+
+**✅ CORRECT**: Join fetch for single queries
+
+```java
+@Service
+public class ContactDetailService {
+
+ private final EntityManager entityManager;
+
+ public Contact getContactWithNotes(Long contactId) {
+ String jpql = "SELECT DISTINCT c FROM Contact c " +
+ "LEFT JOIN FETCH c.notes " +
+ "WHERE c.id = :id";
+
+ return entityManager.createQuery(jpql, Contact.class)
+ .setParameter("id", contactId)
+ .getSingleResult();
+ }
+}
+```
+
+**❌ WRONG**: N+1 query problem
+
+```java
+@Service
+public class BadContactService {
+
+ public List getContactsWithNotes() {
+ // This executes 1 query
+ List contacts = crudService.findAll(Contact.class);
+
+ // This executes N queries (one per contact)
+ for (Contact c : contacts) {
+ c.getNotes().size(); // Triggers lazy loading
+ }
+
+ return contacts;
+ }
+}
+```
+
+### Pattern 3: Pagination
+
+**✅ CORRECT**: Paginate large result sets
+
+```java
+@RestController
+@RequestMapping("/api/contacts")
+public class ContactController {
+
+ private final CrudService crudService;
+
+ @GetMapping
+ public Page list(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+
+ Pageable pageable = PageRequest.of(page, size);
+ return crudService.findAll(Contact.class, pageable);
+ }
+}
+```
+
+---
+
+## Anti-Patterns to Avoid
+
+### ❌ Anti-Pattern 1: God Object
+
+**Problem**: Single class doing too much
+
+```java
+@Service
+public class ContactManagementService {
+
+ // Validates, saves, sends emails, generates reports, exports, etc.
+ // Too many responsibilities!
+}
+```
+
+**Solution**: Break into focused services
+
+```java
+@Service
+public class ContactService {
+ // Only CRUD and basic business logic
+}
+
+@Service
+public class ContactNotificationService {
+ // Email and SMS notifications
+}
+
+@Service
+public class ContactReportService {
+ // Report generation
+}
+
+@Service
+public class ContactExportService {
+ // Data export
+}
+```
+
+### ❌ Anti-Pattern 2: Anemic Entities
+
+**Problem**: Entities with no business logic
+
+```java
+@Entity
+public class Contact {
+
+ private String name;
+ private String email;
+
+ // Only getters and setters, no logic
+}
+```
+
+**Solution**: Add business methods to entities
+
+```java
+@Entity
+public class Contact {
+
+ private String name;
+ private String email;
+ private ContactStatus status;
+
+ // Business methods
+ public boolean isActive() {
+ return status == ContactStatus.ACTIVE;
+ }
+
+ public void activate() {
+ this.status = ContactStatus.ACTIVE;
+ }
+}
+```
+
+### ❌ Anti-Pattern 3: Leaky Abstraction
+
+**Problem**: Exposing database details in service layer
+
+```java
+@Service
+public class ContactService {
+
+ public List getContacts() {
+ // Leaking JPA implementation detail
+ return entityManager.createQuery("SELECT c FROM Contact c").getResultList();
+ }
+}
+```
+
+**Solution**: Use abstraction (CrudService)
+
+```java
+@Service
+public class ContactService {
+
+ private final CrudService crudService;
+
+ public List getContacts() {
+ return crudService.findAll(Contact.class);
+ }
+}
+```
+
+### ❌ Anti-Pattern 4: Tight Coupling
+
+**Problem**: Direct dependencies on concrete classes
+
+```java
+@Service
+public class OrderService {
+
+ private final MySqlContactRepository contactRepository; // Too specific
+
+ public void placeOrder(Order order) {
+ contactRepository.save(order.getContact());
+ }
+}
+```
+
+**Solution**: Depend on abstractions
+
+```java
+@Service
+public class OrderService {
+
+ private final CrudService crudService; // Generic abstraction
+
+ public void placeOrder(Order order) {
+ crudService.save(order.getContact());
+ }
+}
+```
+
+### ❌ Anti-Pattern 5: Silent Failures
+
+**Problem**: Errors not properly reported
+
+```java
+@Service
+public class ContactService {
+
+ public void importContacts(List names) {
+ for (String name : names) {
+ try {
+ Contact contact = new Contact();
+ contact.setName(name);
+ crudService.save(contact);
+ } catch (Exception e) {
+ // Silently ignoring errors
+ e.printStackTrace();
+ }
+ }
+ }
+}
+```
+
+**Solution**: Proper error handling and logging
+
+```java
+@Service
+public class ContactService {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ public ImportResult importContacts(List names) {
+ List successful = new ArrayList<>();
+ List errors = new ArrayList<>();
+
+ for (String name : names) {
+ try {
+ Contact contact = new Contact();
+ contact.setName(name);
+ crudService.save(contact);
+ successful.add(name);
+ } catch (ValidationException e) {
+ logger.warn("Validation failed for contact: {}", name, e);
+ errors.add(new ImportError(name, e.getMessage()));
+ } catch (Exception e) {
+ logger.error("Failed to import contact: {}", name, e);
+ errors.add(new ImportError(name, "Unexpected error: " + e.getMessage()));
+ }
+ }
+
+ return new ImportResult(successful, errors);
+ }
+}
+```
+
+### ❌ Anti-Pattern 6: Mixing Concerns in Views
+
+**Problem**: Business logic in view layer
+
+```java
+public class ContactForm extends Window {
+
+ private final Contact contact;
+
+ public void onSaveClick() {
+ // Validation logic in view
+ if (contact.getName() == null) {
+ showError("Name required");
+ return;
+ }
+
+ // Save logic in view
+ Session session = sessionFactory.openSession();
+ session.save(contact);
+ session.close();
+ }
+}
+```
+
+**Solution**: Keep business logic in services
+
+```java
+public class ContactForm extends Window {
+
+ private final ContactService contactService;
+ private Contact contact;
+
+ public ContactForm(ContactService contactService) {
+ this.contactService = contactService;
+ }
+
+ public void onSaveClick() {
+ try {
+ contactService.saveContact(contact);
+ showSuccess("Contact saved");
+ } catch (ValidationException e) {
+ showError("Validation failed: " + e.getMessage());
+ }
+ }
+}
+```
+
+---
+
+## Summary of Best Practices
+
+1. **Use Constructor Injection** for immutability and testability
+2. **Apply @Transactional** at service method level
+3. **Implement ModuleProvider** for module registration
+4. **Create focused services** with single responsibility
+5. **Use custom validators** for complex validation
+6. **Implement view customizers** for display logic
+7. **Handle exceptions properly** with custom exception types
+8. **Use lazy loading** wisely to avoid N+1 queries
+9. **Paginate large result sets** for performance
+10. **Avoid tight coupling** - depend on abstractions
+
+---
+
+Next: Read [Advanced Topics](./ADVANCED_TOPICS.md) for deep dives into complex scenarios.
+
diff --git a/docs/backend/EXAMPLES.md b/docs/backend/EXAMPLES.md
new file mode 100644
index 00000000..8c34eeba
--- /dev/null
+++ b/docs/backend/EXAMPLES.md
@@ -0,0 +1,1251 @@
+# Examples & Integration
+
+This document provides complete, real-world code examples for the most common tasks in DynamiaTools applications. All examples are based on a **Book Store** domain and can be adapted to any enterprise application.
+
+## Table of Contents
+
+1. [Complete Application Setup](#complete-application-setup)
+2. [Domain Entities](#domain-entities)
+3. [View Descriptors](#view-descriptors)
+4. [Module Provider](#module-provider)
+5. [Custom Actions](#custom-actions)
+6. [Validators](#validators)
+7. [CRUD Listeners](#crud-listeners)
+8. [Services](#services)
+9. [Form Customizers](#form-customizers)
+10. [REST Integration](#rest-integration)
+11. [Extensions Usage](#extensions-usage)
+
+---
+
+## Complete Application Setup
+
+### 1. `pom.xml` — Core Dependencies
+
+```xml
+
+ com.example
+ mybookstore
+ 1.0.0
+ jar
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.0.0
+
+
+
+
+
+ tools.dynamia
+ tools.dynamia.app
+ 26.3.2
+
+
+
+
+ tools.dynamia
+ tools.dynamia.zk
+ 26.3.2
+
+
+
+
+ tools.dynamia
+ tools.dynamia.domain.jpa
+ 26.3.2
+
+
+
+
+ com.h2database
+ h2
+ runtime
+
+
+
+```
+
+### 2. Main Application Class
+
+```java
+package mybookstore;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.persistence.autoconfigure.EntityScan;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import tools.dynamia.app.Ehcache3CacheManager;
+import tools.dynamia.domain.DefaultEntityReferenceRepository;
+import tools.dynamia.domain.EntityReferenceRepository;
+import tools.dynamia.navigation.DefaultPageProvider;
+import tools.dynamia.ui.icons.IconsProvider;
+import tools.dynamia.zk.ui.ZIconsProvider;
+
+@SpringBootApplication
+@EntityScan({"mybookstore", "tools.dynamia"})
+@EnableCaching
+@EnableScheduling
+public class MyBookStoreApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MyBookStoreApplication.class, args);
+ }
+
+ @Bean
+ public CacheManager cacheManager() {
+ return new Ehcache3CacheManager();
+ }
+
+ /** Exposes Category as a named reference list (used in combo boxes) */
+ @Bean
+ public EntityReferenceRepository categoryReferenceRepository() {
+ return new DefaultEntityReferenceRepository<>(Category.class, "name");
+ }
+
+ /** Navigates to this page on first load */
+ @Bean
+ public DefaultPageProvider defaultPageProvider() {
+ return () -> "library/books";
+ }
+
+ /** Use ZK icon set */
+ @Bean
+ public IconsProvider iconsProvider() {
+ return new ZIconsProvider();
+ }
+}
+```
+
+### 3. `application.yml`
+
+```yaml
+spring:
+ jpa:
+ hibernate:
+ ddl-auto: update
+ open-in-view: false
+ show-sql: false
+
+server:
+ port: 8080
+ servlet:
+ session:
+ tracking-modes: cookie
+
+dynamia:
+ app:
+ name: My Book Store
+ short-name: Books
+ version: 1.0.0
+ description: Book inventory and sales management
+ template: Dynamical
+ default-skin: Green
+ default-logo: /static/logo.png
+ base-package: mybookstore
+ web-cache-enabled: true
+```
+
+---
+
+## Domain Entities
+
+### Category (Parent-Child Tree)
+
+```java
+package mybookstore.domain;
+
+import jakarta.persistence.*;
+import tools.dynamia.domain.jpa.BaseEntity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "categories")
+public class Category extends BaseEntity {
+
+ @Column(nullable = false)
+ private String name;
+
+ private String description;
+
+ @ManyToOne
+ private Category parent;
+
+ @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List children = new ArrayList<>();
+
+ public Category() {}
+
+ public Category(String name) {
+ this.name = name;
+ }
+
+ // Getters / Setters
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public Category getParent() { return parent; }
+ public void setParent(Category parent) { this.parent = parent; }
+ public List getChildren() { return children; }
+
+ @Override
+ public String toString() { return name; }
+}
+```
+
+### Book (Rich Domain Entity)
+
+```java
+package mybookstore.domain;
+
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import tools.dynamia.domain.OrderBy;
+import tools.dynamia.domain.jpa.BaseEntity;
+import tools.dynamia.modules.entityfile.domain.EntityFile;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "books")
+@OrderBy("title")
+public class Book extends BaseEntity {
+
+ @NotNull
+ private String title;
+
+ @NotEmpty
+ private String isbn;
+
+ @Column(length = 2000)
+ private String synopsis;
+
+ private int year;
+
+ private LocalDate publishDate;
+ private LocalDate buyDate;
+
+ private BigDecimal price;
+
+ @ManyToOne
+ private Category category;
+
+ @Enumerated(EnumType.STRING)
+ private StockStatus stockStatus = StockStatus.IN_STOCK;
+
+ private boolean onSale;
+ private BigDecimal salePrice;
+ private double discount; // percent
+
+ @OneToOne
+ private EntityFile bookCover;
+
+ @OneToMany(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List reviews = new ArrayList<>();
+
+ // Getters / Setters (abbreviated)
+ public String getTitle() { return title; }
+ public void setTitle(String title) { this.title = title; }
+ public String getIsbn() { return isbn; }
+ public void setIsbn(String isbn) { this.isbn = isbn; }
+ public Category getCategory() { return category; }
+ public void setCategory(Category category) { this.category = category; }
+ public BigDecimal getPrice() { return price; }
+ public void setPrice(BigDecimal price) { this.price = price; }
+ public boolean isOnSale() { return onSale; }
+ public void setOnSale(boolean onSale) { this.onSale = onSale; }
+ public BigDecimal getSalePrice() { return salePrice; }
+ public void setSalePrice(BigDecimal salePrice) { this.salePrice = salePrice; }
+ public EntityFile getBookCover() { return bookCover; }
+ public void setBookCover(EntityFile bookCover) { this.bookCover = bookCover; }
+ public List getReviews() { return reviews; }
+ public StockStatus getStockStatus() { return stockStatus; }
+ public void setStockStatus(StockStatus stockStatus) { this.stockStatus = stockStatus; }
+
+ @Override
+ public String toString() { return title; }
+}
+```
+
+### Invoice with Line Details
+
+```java
+package mybookstore.domain;
+
+import jakarta.persistence.*;
+import tools.dynamia.domain.jpa.BaseEntity;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "invoices")
+public class Invoice extends BaseEntity {
+
+ private String number;
+
+ @ManyToOne
+ private Customer customer;
+
+ private String email;
+
+ @OneToMany(mappedBy = "invoice", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List details = new ArrayList<>();
+
+ private BigDecimal total;
+
+ public void calcTotal() {
+ this.total = details.stream()
+ .map(d -> d.getUnitPrice().multiply(BigDecimal.valueOf(d.getQuantity())))
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
+
+ // Getters / Setters
+ public String getNumber() { return number; }
+ public void setNumber(String number) { this.number = number; }
+ public Customer getCustomer() { return customer; }
+ public void setCustomer(Customer customer) { this.customer = customer; }
+ public List getDetails() { return details; }
+ public BigDecimal getTotal() { return total; }
+}
+```
+
+---
+
+## View Descriptors
+
+View descriptors (YAML files in `src/main/resources/META-INF/descriptors/`) control what users see in forms, tables, and CRUD pages without writing any UI code.
+
+### Book Form (`BookForm.yml`)
+
+```yaml
+view: form
+beanClass: mybookstore.domain.Book
+autofields: false
+customizer: mybookstore.customizers.BookFormCustomizer
+
+fields:
+ title:
+ params:
+ span: 2 # Spans 2 columns
+ category:
+ isbn:
+ year:
+ publishDate:
+ component: dateselector
+ onSale:
+ salePrice:
+ params:
+ format: $###,###
+ buyDate:
+ price:
+ params:
+ format: $###,###
+ synopsis:
+ params:
+ span: 4
+ multiline: true
+ height: 80px
+ discount:
+ params:
+ constraint: "min 0 max 100"
+ bookCover:
+ params:
+ imageOnly: true
+ preview:
+ component: entityfileImage
+ params:
+ thumbnail: true
+ bindings:
+ value: bookCover
+ reviews:
+ component: crudview
+
+groups:
+ details:
+ label: Book Details
+ fields: [buyDate, price, reviews, salePrice]
+
+layout:
+ columns: 4
+```
+
+### Book Table (`BookTable.yml`)
+
+```yaml
+view: table
+beanClass: mybookstore.domain.Book
+autofields: false
+
+fields:
+ bookCover:
+ label: Cover
+ component: entityfileImage
+ params:
+ thumbnail: true
+ renderWhenNull: true
+ header:
+ width: 70px
+ align: center
+
+ title:
+ category:
+
+ stockStatus:
+ label: Stock
+ component: enumlabel
+ params:
+ defaultSclass: stockStatus
+ sclassPrefix: status
+ header:
+ width: 90px
+
+ isbn:
+ year:
+
+ publishDate:
+ params:
+ converter: converters.Date
+
+ price:
+ params:
+ converter: converters.Currency
+ header:
+ align: right
+ sclass: orange color-white
+ cell:
+ sclass: orange lighten-5
+```
+
+### Book CRUD (`BookCrud.yml`)
+
+```yaml
+view: crud
+beanClass: mybookstore.domain.Book
+autofields: false
+
+params:
+ queryProjection: true
+```
+
+### Book Filters (`BookFilters.yml`)
+
+```yaml
+view: entityfilters
+beanClass: mybookstore.domain.Book
+autofields: false
+
+fields:
+ title:
+ category:
+ isbn:
+ stockStatus:
+ publishDate:
+ params:
+ converter: converters.Date
+ price:
+ params:
+ converter: converters.Currency
+ creationTimestamp:
+ label: Created
+ params:
+ converter: converters.LocalDateTime
+ header:
+ width: 120px
+ align: center
+```
+
+### Category Tree (`CategoryTree.yml`)
+
+```yaml
+view: tree
+beanClass: mybookstore.domain.Category
+parentName: parent
+```
+
+### Category CRUD with Tree (`CategoryCrud.yml`)
+
+```yaml
+view: crud
+beanClass: mybookstore.domain.Category
+dataSetView: tree
+parentName: parent
+```
+
+### Invoice Form with Inline Details (`InvoiceForm.yml`)
+
+```yaml
+view: form
+beanClass: mybookstore.domain.Invoice
+autofields: false
+
+fields:
+ number:
+ customer:
+ email:
+ details:
+ component: crudview
+ params:
+ inplace: true
+ height: 400px
+ span: 3
+
+layout:
+ columns: 3
+```
+
+---
+
+## Module Provider
+
+The module provider defines the navigation structure of your application.
+
+```java
+package mybookstore.providers;
+
+import mybookstore.domain.*;
+import org.springframework.stereotype.Component;
+import tools.dynamia.crud.CrudPage;
+import tools.dynamia.integration.sterotypes.Provider;
+import tools.dynamia.navigation.*;
+
+@Provider
+public class MyBookStoreModuleProvider implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ return new Module("library", "Library")
+ .icon("book")
+ .description("Book inventory and sales")
+ .position(0)
+ .addPage(
+ new CrudPage("books", "Books", Book.class),
+ new CrudPage("categories", "Categories", Category.class).icon("tree"),
+ new CrudPage("customers", "Customers", Customer.class).icon("people"),
+ new CrudPage("invoices", "Invoices", Invoice.class)
+ )
+ .addPageGroup(new PageGroup("reports", "Reports")
+ .addPage(
+ new Page("sales-report", "Sales Report", "classpath:/pages/sales-report.zul"),
+ new ExternalPage("docs", "Documentation", "https://dynamia.tools")
+ )
+ );
+ }
+}
+```
+
+### Settings Module
+
+```java
+@Provider
+public class SettingsModuleProvider implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ return new Module("settings", "Settings")
+ .icon("cog")
+ .position(100)
+ .addPage(new CrudPage("categories", "Book Categories", Category.class));
+ }
+}
+```
+
+---
+
+## Custom Actions
+
+### Global Action (always visible in toolbar)
+
+```java
+package mybookstore.actions;
+
+import tools.dynamia.actions.ActionEvent;
+import tools.dynamia.actions.InstallAction;
+import tools.dynamia.actions.ApplicationGlobalAction;
+import tools.dynamia.ui.MessageType;
+import tools.dynamia.ui.UIMessages;
+
+@InstallAction
+public class HelpAction extends ApplicationGlobalAction {
+
+ public HelpAction() {
+ setName("Help");
+ setImage("help");
+ }
+
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ UIMessages.showMessage("Visit https://dynamia.tools for documentation.", MessageType.INFO);
+ }
+}
+```
+
+### CRUD Action (entity-specific button)
+
+```java
+package mybookstore.actions;
+
+import mybookstore.domain.Book;
+import mybookstore.domain.StockStatus;
+import tools.dynamia.actions.InstallAction;
+import tools.dynamia.crud.AbstractCrudAction;
+import tools.dynamia.crud.CrudActionEvent;
+import tools.dynamia.ui.MessageType;
+import tools.dynamia.ui.UIMessages;
+
+@InstallAction
+public class MarkOutOfStockAction extends AbstractCrudAction {
+
+ public MarkOutOfStockAction() {
+ setName("Mark Out of Stock");
+ setApplicableClass(Book.class);
+ setImage("warning");
+ }
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ Book book = (Book) evt.getEntity();
+ if (book == null) {
+ UIMessages.showMessage("Please select a book first.", MessageType.WARNING);
+ return;
+ }
+ book.setStockStatus(StockStatus.OUT_OF_STOCK);
+ crudService().save(book);
+ UIMessages.showMessage("Book marked as out of stock.", MessageType.SUCCESS);
+ }
+}
+```
+
+### Bulk Action (operates on selected rows)
+
+```java
+@InstallAction
+public class BulkDiscountAction extends AbstractCrudAction {
+
+ public BulkDiscountAction() {
+ setName("Apply 10% Discount");
+ setApplicableClass(Book.class);
+ setImage("percent");
+ }
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ @SuppressWarnings("unchecked")
+ List selected = (List) evt.getSelectedEntities();
+ if (selected == null || selected.isEmpty()) {
+ UIMessages.showMessage("Select at least one book.", MessageType.WARNING);
+ return;
+ }
+ selected.forEach(book -> {
+ book.setDiscount(10.0);
+ crudService().save(book);
+ });
+ UIMessages.showMessage(selected.size() + " book(s) updated.", MessageType.SUCCESS);
+ }
+}
+```
+
+### Long-Running Action with Progress Monitor
+
+```java
+@InstallAction
+public class ReindexBooksAction extends AbstractCrudAction {
+
+ public ReindexBooksAction() {
+ setName("Reindex Books");
+ setApplicableClass(Book.class);
+ setImage("refresh");
+ }
+
+ @Override
+ public void actionPerformed(CrudActionEvent evt) {
+ LongOperationMonitorWindow.start("Reindexing books...", "Done", monitor -> {
+ List books = crudService().findAll(Book.class);
+ monitor.setMax(books.size());
+
+ for (Book book : books) {
+ monitor.setMessage("Processing: " + book.getTitle());
+ reindex(book);
+ monitor.increment();
+
+ if (monitor.isStopped()) {
+ throw new ValidationError("Reindex cancelled by user.");
+ }
+ }
+ });
+ }
+
+ private void reindex(Book book) {
+ // Simulate reindexing
+ }
+}
+```
+
+---
+
+## Validators
+
+### Single-Field Validator
+
+```java
+package mybookstore.validators;
+
+import mybookstore.domain.Book;
+import tools.dynamia.domain.ValidationError;
+import tools.dynamia.domain.Validator;
+import tools.dynamia.integration.sterotypes.InstallValidator;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+@InstallValidator
+public class BookValidator implements Validator {
+
+ @Override
+ public List validate(Book book) {
+ List errors = new ArrayList<>();
+
+ if (book.getTitle() == null || book.getTitle().isBlank()) {
+ errors.add(new ValidationError("title", "Title is required"));
+ }
+
+ if (book.getIsbn() == null || book.getIsbn().isBlank()) {
+ errors.add(new ValidationError("isbn", "ISBN is required"));
+ }
+
+ if (book.getPrice() != null && book.getPrice().compareTo(BigDecimal.ZERO) < 0) {
+ errors.add(new ValidationError("price", "Price must be zero or positive"));
+ }
+
+ if (book.isOnSale() && book.getSalePrice() == null) {
+ errors.add(new ValidationError("salePrice", "Sale price is required when book is on sale"));
+ }
+
+ return errors;
+ }
+
+ @Override
+ public Class getValidatedClass() {
+ return Book.class;
+ }
+}
+```
+
+### Cross-Entity Validator
+
+```java
+@InstallValidator
+public class InvoiceValidator implements Validator {
+
+ private final CrudService crudService;
+
+ public InvoiceValidator(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ @Override
+ public List validate(Invoice invoice) {
+ List errors = new ArrayList<>();
+
+ if (invoice.getDetails() == null || invoice.getDetails().isEmpty()) {
+ errors.add(new ValidationError("details", "Invoice must have at least one line"));
+ }
+
+ if (invoice.getNumber() != null) {
+ long count = crudService.count(Invoice.class, "number", invoice.getNumber());
+ if (count > 0 && invoice.isNew()) {
+ errors.add(new ValidationError("number", "Invoice number already exists"));
+ }
+ }
+
+ return errors;
+ }
+
+ @Override
+ public Class getValidatedClass() {
+ return Invoice.class;
+ }
+}
+```
+
+---
+
+## CRUD Listeners
+
+### Invoice Lifecycle Listener
+
+```java
+package mybookstore.listeners;
+
+import mybookstore.domain.Invoice;
+import tools.dynamia.domain.util.CrudServiceListenerAdapter;
+import tools.dynamia.integration.sterotypes.Listener;
+
+@Listener
+public class InvoiceCrudListener extends CrudServiceListenerAdapter {
+
+ @Override
+ public void beforeCreate(Invoice entity) {
+ entity.calcTotal();
+ }
+
+ @Override
+ public void beforeUpdate(Invoice entity) {
+ entity.calcTotal();
+ }
+}
+```
+
+### Audit Listener
+
+```java
+@Listener
+public class BookAuditListener extends CrudServiceListenerAdapter {
+
+ private final ApplicationEventPublisher eventPublisher;
+
+ public BookAuditListener(ApplicationEventPublisher eventPublisher) {
+ this.eventPublisher = eventPublisher;
+ }
+
+ @Override
+ public void afterCreate(Book book) {
+ eventPublisher.publishEvent(new BookCreatedEvent(book));
+ }
+
+ @Override
+ public void beforeDelete(Book book) {
+ // Prevent deletion of books with invoices
+ if (hasActiveInvoices(book)) {
+ throw new ValidationError("Cannot delete a book that appears in invoices.");
+ }
+ }
+
+ private boolean hasActiveInvoices(Book book) {
+ // check in DB
+ return false;
+ }
+}
+```
+
+---
+
+## Services
+
+### Book Service with Business Logic
+
+```java
+package mybookstore.services;
+
+import mybookstore.domain.Book;
+import mybookstore.domain.StockStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import tools.dynamia.domain.services.CrudService;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Service
+@Transactional
+public class BookService {
+
+ private final CrudService crudService;
+
+ public BookService(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ public Book create(Book book) {
+ return crudService.save(book);
+ }
+
+ public List findByCategory(Long categoryId) {
+ return crudService.findAll(Book.class, "category.id", categoryId);
+ }
+
+ public List findOnSale() {
+ return crudService.findAll(Book.class, "onSale", true);
+ }
+
+ public Book applyDiscount(Long bookId, double discountPercent) {
+ Book book = crudService.find(Book.class, bookId);
+ if (book == null) throw new IllegalArgumentException("Book not found: " + bookId);
+
+ book.setDiscount(discountPercent);
+ BigDecimal discountAmount = book.getPrice()
+ .multiply(BigDecimal.valueOf(discountPercent / 100.0));
+ book.setSalePrice(book.getPrice().subtract(discountAmount));
+ book.setOnSale(true);
+
+ return crudService.save(book);
+ }
+
+ public void restock(Long bookId) {
+ Book book = crudService.find(Book.class, bookId);
+ if (book != null) {
+ book.setStockStatus(StockStatus.IN_STOCK);
+ crudService.save(book);
+ }
+ }
+}
+```
+
+---
+
+## Form Customizers
+
+### Conditionally Show/Hide Fields
+
+```java
+package mybookstore.customizers;
+
+import mybookstore.domain.Book;
+import org.zkoss.zk.ui.event.Events;
+import org.zkoss.zul.Checkbox;
+import tools.dynamia.viewers.ViewCustomizer;
+import tools.dynamia.zk.viewers.form.FormFieldComponent;
+import tools.dynamia.zk.viewers.form.FormView;
+
+public class BookFormCustomizer implements ViewCustomizer> {
+
+ @Override
+ public void customize(FormView view) {
+ FormFieldComponent onSale = view.getFieldComponent("onSale");
+ FormFieldComponent salePrice = view.getFieldComponent("salePrice");
+
+ // Hide sale price by default
+ salePrice.hide();
+
+ // Show sale price when book is already on sale (edit mode)
+ view.addEventListener(FormView.ON_VALUE_CHANGED, event -> {
+ if (view.getValue() != null && view.getValue().isOnSale()) {
+ salePrice.show();
+ }
+ });
+
+ // Toggle sale price on checkbox change
+ if (onSale != null && onSale.getInputComponent() instanceof Checkbox checkbox) {
+ checkbox.addEventListener(Events.ON_CHECK, event -> {
+ if (checkbox.isChecked()) {
+ salePrice.show();
+ } else {
+ salePrice.hide();
+ }
+ });
+ }
+ }
+}
+```
+
+---
+
+## REST Integration
+
+### Book REST Controller
+
+```java
+package mybookstore.controllers;
+
+import mybookstore.domain.Book;
+import mybookstore.services.BookService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import tools.dynamia.domain.services.CrudService;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/books")
+public class BookRestController {
+
+ private final CrudService crudService;
+ private final BookService bookService;
+
+ public BookRestController(CrudService crudService, BookService bookService) {
+ this.crudService = crudService;
+ this.bookService = bookService;
+ }
+
+ @GetMapping
+ public List getAll() {
+ return crudService.findAll(Book.class);
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getById(@PathVariable Long id) {
+ Book book = crudService.find(Book.class, id);
+ return book != null ? ResponseEntity.ok(book) : ResponseEntity.notFound().build();
+ }
+
+ @GetMapping("/on-sale")
+ public List getOnSale() {
+ return bookService.findOnSale();
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public Book create(@RequestBody Book book) {
+ return bookService.create(book);
+ }
+
+ @PutMapping("/{id}/discount")
+ public ResponseEntity applyDiscount(
+ @PathVariable Long id,
+ @RequestParam double percent) {
+ return ResponseEntity.ok(bookService.applyDiscount(id, percent));
+ }
+
+ @DeleteMapping("/{id}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void delete(@PathVariable Long id) {
+ Book book = crudService.find(Book.class, id);
+ if (book != null) crudService.delete(book);
+ }
+}
+```
+
+### Sample Data Initializer
+
+```java
+package mybookstore;
+
+import mybookstore.domain.*;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import tools.dynamia.domain.services.CrudService;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+@Component
+@Order(1)
+public class InitSampleDataCommandLinerRunner implements CommandLineRunner {
+
+ private final CrudService crudService;
+
+ public InitSampleDataCommandLinerRunner(CrudService crudService) {
+ this.crudService = crudService;
+ }
+
+ @Override
+ public void run(String... args) {
+ if (crudService.count(Category.class) == 0) {
+ Category fiction = crudService.save(new Category("Fiction"));
+ Category tech = crudService.save(new Category("Technology"));
+
+ Book b1 = new Book();
+ b1.setTitle("Clean Code");
+ b1.setIsbn("978-0132350884");
+ b1.setPrice(new BigDecimal("39.99"));
+ b1.setCategory(tech);
+ b1.setPublishDate(LocalDate.of(2008, 8, 1));
+ crudService.save(b1);
+
+ Book b2 = new Book();
+ b2.setTitle("The Great Gatsby");
+ b2.setIsbn("978-0743273565");
+ b2.setPrice(new BigDecimal("12.99"));
+ b2.setCategory(fiction);
+ b2.setPublishDate(LocalDate.of(1925, 4, 10));
+ crudService.save(b2);
+ }
+ }
+}
+```
+
+---
+
+## Extensions Usage
+
+### Using Entity Files (File Attachments)
+
+**Dependency**:
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.entityfiles
+ 26.3.2
+
+```
+
+**Entity**:
+```java
+@Entity
+public class Book extends BaseEntity {
+
+ @OneToOne
+ private EntityFile bookCover; // Single file reference
+
+ // ...
+}
+```
+
+**In view descriptor** (`BookForm.yml`):
+```yaml
+fields:
+ bookCover:
+ params:
+ imageOnly: true
+ preview:
+ component: entityfileImage
+ params:
+ thumbnail: true
+ bindings:
+ value: bookCover
+```
+
+---
+
+### Using Email Extension
+
+**Dependency**:
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.email
+ 26.3.2
+
+```
+
+**Send Welcome Email on Customer Creation**:
+```java
+@Listener
+public class CustomerCrudListener extends CrudServiceListenerAdapter {
+
+ private final EmailService emailService;
+
+ public CustomerCrudListener(EmailService emailService) {
+ this.emailService = emailService;
+ }
+
+ @Override
+ public void afterCreate(Customer customer) {
+ Message message = new Message();
+ message.setTo(customer.getEmail());
+ message.setSubject("Welcome to My Book Store!");
+ message.setBody("Hello " + customer.getName() + ", thanks for registering.");
+ emailService.send(message);
+ }
+}
+```
+
+---
+
+### Using SaaS Multi-Tenancy
+
+**Dependency**:
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.saas
+ 26.3.2
+
+```
+
+**Tenant-Aware Entity**:
+```java
+@Entity
+public class Book extends BaseEntity {
+
+ @ManyToOne
+ private Account account; // Links this record to a tenant account
+
+ private String title;
+ // ...
+}
+```
+
+**Query current tenant's books**:
+```java
+@Service
+public class BookService {
+
+ private final TenantProvider tenantProvider;
+ private final CrudService crudService;
+
+ public List getCurrentTenantBooks() {
+ Account account = tenantProvider.getCurrentAccount();
+ return crudService.findAll(Book.class, "account", account);
+ }
+}
+```
+
+---
+
+### Using Finance Framework
+
+**Dependency**:
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.finances
+ 26.3.2
+
+```
+
+**Calculate Invoice Total with Tax and Discount**:
+```java
+public FinancialSummary calculateInvoice(Invoice invoice) {
+ FinancialCalculator calculator = new FinancialCalculator();
+
+ for (InvoiceDetail detail : invoice.getDetails()) {
+ calculator.addCharge(new Charge()
+ .setType(ChargeType.LINE_ITEM)
+ .setAmount(detail.getUnitPrice().multiply(BigDecimal.valueOf(detail.getQuantity())))
+ .setTaxable(true));
+ }
+
+ // 5% discount
+ calculator.addCharge(new Charge()
+ .setType(ChargeType.DISCOUNT)
+ .setPercentage(5));
+
+ // 19% VAT
+ calculator.addCharge(new Charge()
+ .setType(ChargeType.TAX)
+ .setPercentage(19));
+
+ return calculator.calculate();
+}
+```
+
+---
+
+## Summary
+
+These examples demonstrate:
+
+| Topic | Pattern |
+|-------|---------|
+| **Application Setup** | `@SpringBootApplication` + `@EntityScan` + `@EnableCaching` |
+| **Domain Entities** | `extends BaseEntity` + JPA annotations |
+| **View Descriptors** | YAML files in `META-INF/descriptors/` |
+| **Module Provider** | `implements ModuleProvider` + `@Provider` |
+| **Actions** | `extends AbstractCrudAction` + `@InstallAction` |
+| **Validators** | `implements Validator` + `@InstallValidator` |
+| **CRUD Listeners** | `extends CrudServiceListenerAdapter` + `@Listener` |
+| **Services** | `@Service` + constructor-injected `CrudService` |
+| **Form Customizers** | `implements ViewCustomizer>` |
+| **REST Controllers** | `@RestController` + `@RequestMapping` |
+| **Extensions** | Add dependency + implement/use provided interfaces |
+
+All patterns follow DynamiaTools conventions: minimal boilerplate, Spring-managed beans, and metadata-driven UI generation.
+
+---
+
+Next: Explore [Advanced Topics](./ADVANCED_TOPICS.md) for Spring integration, security, caching, and microservice patterns.
+
diff --git a/docs/backend/EXTENSIONS.md b/docs/backend/EXTENSIONS.md
new file mode 100644
index 00000000..c4424a11
--- /dev/null
+++ b/docs/backend/EXTENSIONS.md
@@ -0,0 +1,1242 @@
+# Enterprise Extensions Guide
+
+DynamiaTools includes powerful, production-ready extensions that solve common enterprise challenges. This guide provides an overview of each extension, its purpose, key features, and integration patterns.
+
+## Table of Contents
+
+1. [Extensions Overview](#extensions-overview)
+2. [SaaS (Multi-Tenancy) Extension](#saas-multi-tenancy-extension)
+3. [Entity Files Extension](#entity-files-extension)
+4. [Email & SMS Extension](#email--sms-extension)
+5. [Dashboard Extension](#dashboard-extension)
+6. [Reports Extension](#reports-extension)
+7. [Finance Framework](#finance-framework)
+8. [File Importer Extension](#file-importer-extension)
+9. [Security Extension](#security-extension)
+10. [HTTP Functions Extension](#http-functions-extension)
+11. [Extension Integration Patterns](#extension-integration-patterns)
+
+---
+
+## Extensions Overview
+
+All extensions are located in `/extensions/` and follow consistent patterns:
+
+```
+extensions/
+├── saas/ # Multi-tenancy
+├── entity-files/ # File attachments
+├── email-sms/ # Communication
+├── dashboard/ # Dashboards
+├── reports/ # Advanced reporting
+├── finances/ # Financial calculations
+├── file-importer/ # Data import
+├── security/ # Auth & authorization
+└── http-functions/ # HTTP functions
+```
+
+### Extension Dependency Format
+
+All extensions use CalVer versioning (same as core platform):
+
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.EXTENSION_NAME
+ 26.3.2
+
+```
+
+### Common Extension Pattern
+
+Most extensions follow this structure:
+
+```
+extension-name/sources/
+├── api/ # Public interfaces
+├── core/ or jpa/ # Implementation
+├── ui/ # UI components
+└── pom.xml
+```
+
+---
+
+## SaaS (Multi-Tenancy) Extension
+
+**Artifact ID**: `tools.dynamia.modules.saas`
+
+**Purpose**: Build multi-tenant applications where each customer has isolated data and configuration.
+
+### Key Features
+
+- **Account Management**: Create and manage customer accounts
+- **Data Isolation**: Per-tenant data segregation
+- **Subscription Handling**: Subscription and payment tracking
+- **Tenant Context**: Automatic tenant filtering
+- **Shared Infrastructure**: Single database for multiple tenants
+
+### What the Extension Provides
+
+- Account entity and management
+- Subscription and billing support
+- Automatic tenant filtering in queries
+- Multi-tenant context management
+- Per-tenant configuration
+
+### Adding the Dependency
+
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.saas
+ 26.3.2
+
+```
+
+### Using SaaS Features
+
+#### 1. Making Entities Tenant-Aware
+
+```java
+@Entity
+public class Contact extends BaseEntity {
+
+ @ManyToOne
+ private Account account; // Add this to make tenant-aware
+
+ private String name;
+ private String email;
+
+ // Getters and setters
+}
+```
+
+#### 2. Automatic Tenant Filtering
+
+```java
+@Service
+public class ContactService {
+
+ private final CrudService crudService;
+ private final TenantProvider tenantProvider;
+
+ public List getContactsForCurrentAccount() {
+ // TenantProvider automatically filters by current tenant
+ Account currentAccount = tenantProvider.getCurrentAccount();
+ return crudService.findAll(Contact.class, "account", currentAccount);
+ }
+}
+```
+
+#### 3. Account Management
+
+```java
+@Service
+public class AccountService {
+
+ private final CrudService crudService;
+
+ public Account createAccount(String name, String plan) {
+ Account account = new Account();
+ account.setName(name);
+ account.setPlan(plan);
+ account.setStatus("ACTIVE");
+ return crudService.save(account);
+ }
+
+ public Subscription addSubscription(Account account, String planType) {
+ Subscription subscription = new Subscription();
+ subscription.setAccount(account);
+ subscription.setPlanType(planType);
+ subscription.setStartDate(LocalDate.now());
+ return crudService.save(subscription);
+ }
+}
+```
+
+### Configuration
+
+```properties
+# Multi-tenant mode
+dynamia.saas.enabled=true
+dynamia.saas.mode=ACCOUNT_ISOLATION
+
+# Database strategy
+dynamia.saas.database-strategy=SHARED_DATABASE
+
+# Automatic tenant context
+dynamia.saas.auto-tenant-context=true
+```
+
+### When to Use SaaS Extension
+
+- Building SaaS products
+- Multi-customer applications
+- Account-based data isolation
+- Subscription management
+- Per-account configuration
+
+---
+
+## Entity Files Extension
+
+**Artifact ID**: `tools.dynamia.modules.entityfiles` and `tools.dynamia.modules.entityfiles.s3`
+
+**Purpose**: Attach files to any entity with storage options (local disk or AWS S3).
+
+### Key Features
+
+- **File Attachment**: Attach files to entities
+- **Multiple Files**: Multiple files per entity
+- **Storage Options**: Local disk or AWS S3
+- **File Metadata**: Track file information
+- **Automatic Cleanup**: Remove files when entities deleted
+- **Direct Download**: Secure file access
+
+### Adding the Dependency
+
+```xml
+
+
+ tools.dynamia.modules
+ tools.dynamia.modules.entityfiles
+ 26.3.2
+
+
+
+
+ tools.dynamia.modules
+ tools.dynamia.modules.entityfiles.s3
+ 26.3.2
+
+```
+
+### Using Entity Files
+
+#### 1. Mark Entity for File Attachment
+
+```java
+@Entity
+public class Contact extends BaseEntity implements HasFiles {
+
+ private String name;
+ private String email;
+
+ // From HasFiles interface - file management
+ @OneToMany(mappedBy = "entity", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List files = new ArrayList<>();
+
+ @Override
+ public List getFiles() {
+ return files;
+ }
+
+ @Override
+ public void addFile(EntityFile file) {
+ files.add(file);
+ }
+}
+```
+
+#### 2. File Upload
+
+```java
+@Service
+public class ContactFileService {
+
+ private final EntityFileManager entityFileManager;
+
+ public EntityFile uploadProfilePicture(Contact contact, MultipartFile file) throws IOException {
+ EntityFile entityFile = new EntityFile();
+ entityFile.setEntity(contact);
+ entityFile.setName(file.getOriginalFilename());
+ entityFile.setContentType(file.getContentType());
+ entityFile.setSize(file.getSize());
+
+ return entityFileManager.save(entityFile, file.getInputStream());
+ }
+}
+```
+
+#### 3. File Download
+
+```java
+@RestController
+@RequestMapping("/api/files")
+public class FileDownloadController {
+
+ private final EntityFileManager entityFileManager;
+
+ @GetMapping("/{fileId}")
+ public ResponseEntity downloadFile(@PathVariable Long fileId) {
+ EntityFile file = entityFileManager.find(fileId);
+ InputStream inputStream = entityFileManager.getInputStream(file);
+
+ return ResponseEntity.ok()
+ .header("Content-Disposition", "attachment; filename=\"" + file.getName() + "\"")
+ .body(new InputStreamResource(inputStream));
+ }
+}
+```
+
+### Storage Configuration
+
+#### Local Storage
+
+```properties
+# Local disk storage (default)
+dynamia.entityfiles.storage=local
+dynamia.entityfiles.local.base-directory=/app/files
+```
+
+#### AWS S3 Storage
+
+```properties
+# AWS S3 storage
+dynamia.entityfiles.storage=s3
+dynamia.entityfiles.s3.bucket-name=my-bucket
+dynamia.entityfiles.s3.region=us-east-1
+dynamia.entityfiles.s3.access-key=${AWS_ACCESS_KEY}
+dynamia.entityfiles.s3.secret-key=${AWS_SECRET_KEY}
+```
+
+### When to Use Entity Files Extension
+
+- Attach documents to records
+- Store user profiles or avatars
+- Manage entity-related files
+- Build document management features
+- Track file metadata
+
+---
+
+## Email & SMS Extension
+
+**Artifact ID**: `tools.dynamia.modules.email`
+
+**Purpose**: Send professional emails and SMS messages with templates and tracking.
+
+### Key Features
+
+- **Email Sending**: JavaMail-based email
+- **SMS Delivery**: AWS SNS integration
+- **Templates**: Reusable email/SMS templates
+- **Accounts**: Multiple email/SMS accounts
+- **Logging**: Delivery tracking
+- **Async Sending**: Non-blocking message delivery
+
+### Adding the Dependency
+
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.email
+ 26.3.2
+
+```
+
+### Using Email Features
+
+#### 1. Send Simple Email
+
+```java
+@Service
+public class ContactNotificationService {
+
+ private final EmailService emailService;
+
+ public void sendWelcomeEmail(Contact contact) {
+ Message message = new Message();
+ message.setTo(contact.getEmail());
+ message.setSubject("Welcome to Our Platform");
+ message.setBody("Thank you for registering, " + contact.getName());
+
+ emailService.send(message);
+ }
+}
+```
+
+#### 2. Send Email with Template
+
+```java
+@Service
+public class TemplatedEmailService {
+
+ private final EmailService emailService;
+ private final TemplateEngine templateEngine;
+
+ public void sendPersonalizedEmail(Contact contact) {
+ Map data = new HashMap<>();
+ data.put("firstName", contact.getFirstName());
+ data.put("company", contact.getCompany().getName());
+
+ String body = templateEngine.render("welcome-email", data);
+
+ Message message = new Message();
+ message.setTo(contact.getEmail());
+ message.setSubject("Welcome, " + contact.getFirstName());
+ message.setBody(body);
+ message.setHtml(true);
+
+ emailService.send(message);
+ }
+}
+```
+
+#### 3. Send SMS
+
+```java
+@Service
+public class SmsNotificationService {
+
+ private final SmsService smsService;
+
+ public void sendActivationCode(Contact contact, String code) {
+ SmsMessage sms = new SmsMessage();
+ sms.setPhoneNumber(contact.getPhone());
+ sms.setContent("Your activation code is: " + code);
+
+ smsService.send(sms);
+ }
+}
+```
+
+#### 4. Email Account Configuration
+
+```java
+@Configuration
+public class EmailConfig {
+
+ @Bean
+ public EmailAccount defaultEmailAccount() {
+ EmailAccount account = new EmailAccount();
+ account.setName("Default");
+ account.setEmail("noreply@example.com");
+ account.setSmtpHost("smtp.gmail.com");
+ account.setSmtpPort(587);
+ account.setUsername("your-email@gmail.com");
+ account.setPassword("your-app-password");
+ account.setUsesTls(true);
+ return account;
+ }
+}
+```
+
+### Configuration
+
+```properties
+# Email settings
+dynamia.email.enabled=true
+dynamia.email.default-account=Default
+dynamia.email.async=true
+
+# SMTP settings
+dynamia.email.smtp.host=smtp.gmail.com
+dynamia.email.smtp.port=587
+dynamia.email.smtp.username=${EMAIL_USERNAME}
+dynamia.email.smtp.password=${EMAIL_PASSWORD}
+
+# SMS settings (AWS SNS)
+dynamia.sms.enabled=true
+dynamia.sms.provider=aws-sns
+dynamia.sms.aws.region=us-east-1
+dynamia.sms.aws.access-key=${AWS_ACCESS_KEY}
+dynamia.sms.aws.secret-key=${AWS_SECRET_KEY}
+```
+
+### When to Use Email & SMS Extension
+
+- Send transactional emails
+- Deliver SMS notifications
+- Marketing communications
+- Delivery confirmations
+- Account verification
+- Alerts and reminders
+
+---
+
+## Dashboard Extension
+
+**Artifact ID**: `tools.dynamia.modules.dashboard`
+
+**Purpose**: Create beautiful, responsive dashboards with customizable widgets.
+
+### Key Features
+
+- **Widgets**: Pre-built dashboard widgets
+- **Charts**: Chart.js integration
+- **Responsive Layout**: Mobile-friendly design
+- **Customizable**: Widget drag-and-drop, theme support
+- **Real-time Updates**: WebSocket support for live data
+- **Permissions**: Widget-level access control
+
+### Adding the Dependency
+
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.dashboard
+ 26.3.2
+
+```
+
+### Creating Dashboard Widgets
+
+#### 1. Custom Widget
+
+```java
+@Component
+public class ContactCountWidget extends AbstractDashboardWidget {
+
+ private final CrudService crudService;
+
+ public ContactCountWidget(CrudService crudService) {
+ super("contactCount", "Contact Count", "chart-bar");
+ this.crudService = crudService;
+ }
+
+ @Override
+ public Map getData() {
+ long totalContacts = crudService.count(Contact.class);
+ long activeContacts = crudService.count(Contact.class, "active", true);
+
+ return Map.of(
+ "total", totalContacts,
+ "active", activeContacts,
+ "percentage", (activeContacts * 100) / totalContacts
+ );
+ }
+
+ @Override
+ public String getTemplateId() {
+ return "contact-count-widget";
+ }
+}
+```
+
+#### 2. Chart Widget
+
+```java
+@Component
+public class ContactsByCityWidget extends AbstractDashboardWidget {
+
+ private final CrudService crudService;
+
+ public ContactsByCityWidget(CrudService crudService) {
+ super("contactsByCity", "Contacts by City", "chart-pie");
+ this.crudService = crudService;
+ }
+
+ @Override
+ public Map getData() {
+ // Query and group by city
+ List results = crudService.find(Contact.class)
+ .stream()
+ .collect(Collectors.groupingByConcurrent(Contact::getCity, Collectors.counting()))
+ .entrySet()
+ .stream()
+ .map(e -> new Object[]{e.getKey(), e.getValue()})
+ .toList();
+
+ return Map.of("data", results);
+ }
+}
+```
+
+#### 3. Dashboard Page
+
+```java
+@Component
+public class DashboardModuleProvider implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ Module module = new Module("dashboard", "Dashboard");
+
+ DashboardPage dashboardPage = new DashboardPage("main", "Executive Dashboard");
+ dashboardPage.addWidget("contactCount");
+ dashboardPage.addWidget("contactsByCity");
+ dashboardPage.addWidget("revenueChart");
+
+ module.addPage(dashboardPage);
+ return module;
+ }
+}
+```
+
+### When to Use Dashboard Extension
+
+- Executive dashboards
+- Real-time monitoring
+- KPI tracking
+- Business intelligence
+- Analytics and reporting
+
+---
+
+## Reports Extension
+
+**Artifact ID**: `tools.dynamia.reports.core`
+
+**Purpose**: Advanced reporting framework with multiple export formats and visualization.
+
+### Key Features
+
+- **Query Builders**: JPQL and native SQL
+- **Export Formats**: CSV, Excel, PDF
+- **Charts**: Built-in chart support
+- **Templates**: Reusable report templates
+- **Scheduling**: Scheduled report generation
+- **Email Delivery**: Send reports via email
+- **Caching**: Performance optimization
+
+### Adding the Dependency
+
+```xml
+
+ tools.dynamia.reports
+ tools.dynamia.reports.core
+ 26.3.2
+
+```
+
+### Creating Reports
+
+#### 1. Simple Report
+
+```java
+@Service
+public class ContactListReportService {
+
+ private final ReportService reportService;
+
+ public Report generateContactReport() {
+ Report report = new Report();
+ report.setName("contact-list");
+ report.setTitle("All Contacts");
+ report.setQuery("SELECT c FROM Contact c ORDER BY c.name");
+
+ report.addColumn("id", "ID");
+ report.addColumn("name", "Name");
+ report.addColumn("email", "Email");
+ report.addColumn("company.name", "Company");
+
+ return report;
+ }
+}
+```
+
+#### 2. Report with Filtering
+
+```java
+@Service
+public class FilteredReportService {
+
+ private final ReportService reportService;
+
+ public Report generateActiveContactsReport(String city) {
+ Report report = new Report();
+ report.setName("active-contacts-by-city");
+ report.setTitle("Active Contacts in " + city);
+
+ String query = "SELECT c FROM Contact c WHERE c.active = true AND c.city = :city";
+ report.setQuery(query);
+ report.addParameter("city", city);
+
+ report.addColumn("name", "Name");
+ report.addColumn("email", "Email");
+ report.addColumn("phone", "Phone");
+
+ return report;
+ }
+}
+```
+
+#### 3. Report Export
+
+```java
+@RestController
+@RequestMapping("/api/reports")
+public class ReportExportController {
+
+ private final ReportService reportService;
+
+ @GetMapping("/{reportId}/export")
+ public ResponseEntity exportReport(
+ @PathVariable String reportId,
+ @RequestParam(defaultValue = "EXCEL") String format) {
+
+ Report report = reportService.getReport(reportId);
+ byte[] data = reportService.export(report, ExportFormat.valueOf(format));
+
+ return ResponseEntity.ok()
+ .header("Content-Disposition", "attachment; filename=\"" + report.getName() + "." + format.toLowerCase() + "\"")
+ .body(new InputStreamResource(new ByteArrayInputStream(data)));
+ }
+}
+```
+
+### When to Use Reports Extension
+
+- Business intelligence
+- Data export
+- Compliance reporting
+- Analytics dashboards
+- Scheduled report generation
+
+---
+
+## Finance Framework
+
+**Artifact ID**: `tools.dynamia.modules.finances`
+
+**Purpose**: Pure Java financial calculation framework for invoices, quotes, POs with taxes, discounts, and withholdings.
+
+### Key Features
+
+- **Immutable Value Objects**: Money, exchange rates, totals
+- **Strategy Pattern**: Pluggable calculation strategies
+- **No Persistence**: Pure domain logic, not tied to database
+- **No Framework Dependencies**: Works with any Java project
+- **Deterministic Results**: Consistent calculations
+- **Fully Tested**: Comprehensive test coverage
+
+### What the Extension Does NOT Include
+
+❌ User Interface (no forms or views)
+❌ Persistence (no database logic)
+❌ Accounting (no double-entry bookkeeping)
+❌ Tax legislation (country-specific rules)
+❌ Electronic invoicing (XML, signatures)
+❌ Workflow (approvals, status transitions)
+❌ Numbering (document sequences)
+
+### Adding the Dependency
+
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.finances
+ 26.3.2
+
+```
+
+### Using Finance Framework
+
+#### 1. Simple Invoice Calculation
+
+```java
+public class InvoiceCalculator {
+
+ public FinancialSummary calculateInvoice(Invoice invoice) {
+ FinancialCalculator calculator = new FinancialCalculator();
+
+ // Add line items
+ for (InvoiceLine line : invoice.getLines()) {
+ calculator.addCharge(
+ new Charge()
+ .setType(ChargeType.LINE_ITEM)
+ .setAmount(line.getUnitPrice().multiply(line.getQuantity()))
+ .setTaxable(true)
+ );
+ }
+
+ // Add discount
+ if (invoice.getDiscount() != null) {
+ calculator.addCharge(
+ new Charge()
+ .setType(ChargeType.DISCOUNT)
+ .setAmount(invoice.getDiscount())
+ );
+ }
+
+ // Add tax
+ calculator.addCharge(
+ new Charge()
+ .setType(ChargeType.TAX)
+ .setPercentage(invoice.getTaxRate())
+ );
+
+ return calculator.calculate();
+ }
+}
+```
+
+#### 2. Multi-Currency Support
+
+```java
+public class MultiCurrencyCalculator {
+
+ public FinancialSummary calculate(Invoice invoice, ExchangeRate rate) {
+ FinancialCalculator calculator = new FinancialCalculator();
+
+ // Add charges in original currency
+ // Then apply exchange rate
+
+ for (InvoiceLine line : invoice.getLines()) {
+ Money amount = line.getUnitPrice()
+ .multiply(line.getQuantity())
+ .convertTo(rate);
+
+ calculator.addCharge(
+ new Charge()
+ .setAmount(amount)
+ );
+ }
+
+ return calculator.calculate();
+ }
+}
+```
+
+#### 3. Complex Pricing
+
+```java
+public class ComplexPricingCalculator {
+
+ public FinancialSummary calculate(PurchaseOrder po) {
+ FinancialCalculator calculator = new FinancialCalculator();
+
+ // Line items
+ for (POLine line : po.getLines()) {
+ calculator.addCharge(new Charge()
+ .setType(ChargeType.LINE_ITEM)
+ .setAmount(Money.of(line.getPrice(), Currency.USD))
+ );
+ }
+
+ // Volume discount
+ if (po.getLines().size() > 10) {
+ calculator.addCharge(new Charge()
+ .setType(ChargeType.DISCOUNT)
+ .setPercentage(5)
+ );
+ }
+
+ // Tax
+ calculator.addCharge(new Charge()
+ .setType(ChargeType.TAX)
+ .setPercentage(po.getTaxRate())
+ );
+
+ // Withholding
+ calculator.addCharge(new Charge()
+ .setType(ChargeType.WITHHOLDING)
+ .setPercentage(3)
+ );
+
+ return calculator.calculate();
+ }
+}
+```
+
+### When to Use Finance Framework
+
+- E-commerce pricing
+- Invoice generation
+- Purchase order calculations
+- Subscription billing
+- Tax calculations
+- Complex pricing logic
+
+---
+
+## File Importer Extension
+
+**Artifact ID**: `tools.dynamia.modules.fileimporter`
+
+**Purpose**: Import bulk data from Excel/CSV files with validation and error handling.
+
+### Key Features
+
+- **Format Support**: Excel (.xlsx) and CSV
+- **Field Mapping**: Map file columns to entity properties
+- **Validation**: Pre-import and row-level validation
+- **Error Handling**: Detailed error reporting
+- **Batch Processing**: Efficient bulk import
+- **Dry Run**: Preview before import
+
+### Adding the Dependency
+
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.fileimporter
+ 26.3.2
+
+```
+
+### Using File Importer
+
+#### 1. Configure Import
+
+```java
+@Service
+public class ContactImportService {
+
+ private final FileImporterService importerService;
+ private final CrudService crudService;
+
+ public ImportResult importContactsFromExcel(MultipartFile file) {
+ ImportConfiguration config = new ImportConfiguration();
+ config.setTargetClass(Contact.class);
+ config.setBatchSize(100);
+ config.setValidateBeforeImport(true);
+
+ // Map file columns to properties
+ config.addColumnMapping(0, "name"); // Column A → name
+ config.addColumnMapping(1, "email"); // Column B → email
+ config.addColumnMapping(2, "phone"); // Column C → phone
+ config.addColumnMapping(3, "company.id"); // Column D → company.id
+
+ return importerService.importFile(file, config);
+ }
+}
+```
+
+#### 2. Custom Import Handler
+
+```java
+@Service
+public class CustomContactImporter {
+
+ private final CrudService crudService;
+
+ public ImportResult importContacts(File file) {
+ List contacts = new ArrayList<>();
+ List errors = new ArrayList<>();
+
+ // Read Excel file
+ ExcelReader reader = new ExcelReader(file);
+ int rowNumber = 0;
+
+ for (Map row : reader.readRows()) {
+ rowNumber++;
+
+ try {
+ Contact contact = new Contact();
+ contact.setName(row.get("name"));
+ contact.setEmail(row.get("email"));
+ contact.setPhone(row.get("phone"));
+
+ // Validation
+ if (contact.getEmail() == null) {
+ errors.add(new ImportError(rowNumber, "email", "Email is required"));
+ continue;
+ }
+
+ contacts.add(contact);
+
+ } catch (Exception e) {
+ errors.add(new ImportError(rowNumber, "general", e.getMessage()));
+ }
+ }
+
+ // Save if no errors
+ if (errors.isEmpty()) {
+ crudService.save(contacts);
+ }
+
+ return new ImportResult(contacts.size(), errors);
+ }
+}
+```
+
+### When to Use File Importer Extension
+
+- Bulk data import
+- Customer migration
+- Inventory updates
+- Periodic data synchronization
+- Legacy system migration
+
+---
+
+## Security Extension
+
+**Artifact ID**: `tools.dynamia.modules.security`
+
+**Purpose**: Enterprise authentication and authorization with role-based access control.
+
+### Key Features
+
+- **User Management**: User and role management
+- **RBAC**: Role-based access control
+- **Password Security**: Bcrypt hashing, password policies
+- **Audit Trail**: Track user actions
+- **OAuth2/OIDC**: External identity provider support
+- **Multi-factor Auth**: Optional 2FA support
+
+### Adding the Dependency
+
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.security
+ 26.3.2
+
+```
+
+### Using Security Features
+
+#### 1. Define Roles and Permissions
+
+```java
+@Configuration
+public class SecurityConfig {
+
+ @Bean
+ public SecurityProfile adminProfile() {
+ SecurityProfile admin = new SecurityProfile();
+ admin.setName("ADMIN");
+ admin.addPermission("contacts", "READ");
+ admin.addPermission("contacts", "CREATE");
+ admin.addPermission("contacts", "UPDATE");
+ admin.addPermission("contacts", "DELETE");
+ admin.addPermission("system", "MANAGE");
+ return admin;
+ }
+
+ @Bean
+ public SecurityProfile userProfile() {
+ SecurityProfile user = new SecurityProfile();
+ user.setName("USER");
+ user.addPermission("contacts", "READ");
+ user.addPermission("contacts", "CREATE");
+ user.addPermission("contacts", "UPDATE");
+ return user;
+ }
+}
+```
+
+#### 2. Secure Methods
+
+```java
+@Service
+public class ContactService {
+
+ @Secured("ROLE_ADMIN")
+ public void deleteContact(Long id) {
+ // Only admins can delete
+ }
+
+ @PreAuthorize("hasPermission('contacts', 'READ')")
+ public List getAllContacts() {
+ // Requires read permission
+ }
+
+ @PreAuthorize("hasPermission(#contact, 'UPDATE')")
+ public Contact updateContact(Contact contact) {
+ // Requires update permission on this contact
+ }
+}
+```
+
+#### 3. User Authentication
+
+```java
+@Service
+public class AuthService {
+
+ private final UserService userService;
+ private final PasswordEncoder passwordEncoder;
+
+ public AuthResponse login(String email, String password) {
+ User user = userService.findByEmail(email);
+
+ if (user != null && passwordEncoder.matches(password, user.getPasswordHash())) {
+ // Generate JWT token
+ String token = generateJWT(user);
+ return new AuthResponse(token, user);
+ }
+
+ throw new AuthenticationException("Invalid credentials");
+ }
+}
+```
+
+### When to Use Security Extension
+
+- User authentication
+- Role-based access control
+- Permission management
+- Audit trail
+- Single sign-on (OAuth2/OIDC)
+
+---
+
+## HTTP Functions Extension
+
+**Artifact ID**: `tools.dynamia.modules.http` (if available)
+
+**Purpose**: Create serverless-style HTTP functions that can be triggered by HTTP requests.
+
+### Key Features
+
+- **Function Registration**: Define HTTP endpoints as functions
+- **Automatic Routing**: Route HTTP requests to functions
+- **Request/Response Handling**: Automatic serialization/deserialization
+- **Error Handling**: Global error handling for functions
+- **Authentication**: Optional authentication per function
+
+### When to Use HTTP Functions Extension
+
+- Create custom REST endpoints
+- Integrate external webhooks
+- Build event handlers
+- Microservice endpoints
+
+---
+
+## Extension Integration Patterns
+
+### Pattern 1: Stacking Extensions
+
+```java
+@SpringBootApplication
+@EnableDynamiaTools
+public class MyApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MyApplication.class, args);
+ }
+}
+
+// Dependencies include:
+// - Core modules (automatic)
+// - SaaS extension (automatic tenant filtering)
+// - Entity Files extension (file attachment)
+// - Email extension (notifications)
+// - Reports extension (analytics)
+```
+
+### Pattern 2: Conditional Extension Usage
+
+```java
+@Configuration
+@ConditionalOnProperty(name = "dynamia.saas.enabled", havingValue = "true")
+public class SaasConfig {
+
+ @Bean
+ public TenantProvider tenantProvider() {
+ return new AccountBasedTenantProvider();
+ }
+}
+```
+
+### Pattern 3: Custom Extension
+
+```java
+@Component
+public class CustomFeatureProvider implements ModuleProvider {
+
+ @Override
+ public Module getModule() {
+ Module module = new Module("custom", "Custom Features");
+
+ // Integrate with SaaS
+ // Integrate with Files
+ // Integrate with Email
+
+ return module;
+ }
+}
+```
+
+### Pattern 4: Extension Interoperability
+
+```java
+@Service
+public class NotificationService {
+
+ // Uses Email extension
+ private final EmailService emailService;
+
+ // Uses File attachment
+ private final EntityFileManager fileManager;
+
+ // Uses Security for permissions
+ @PreAuthorize("hasPermission('notifications', 'MANAGE')")
+ public void sendNotificationWithAttachment(User user, String message, File attachment) {
+ // Implementation combining multiple extensions
+ }
+}
+```
+
+---
+
+## Extension Development Best Practices
+
+### 1. **Follow Module Structure**
+```
+extension-name/
+├── sources/api/ # Public interfaces
+├── sources/core/ # Implementation
+├── sources/ui/ # UI components
+└── sources/pom.xml
+```
+
+### 2. **Use Dependency Injection**
+```java
+@Component
+public class ExtensionService {
+
+ private final CrudService crudService;
+ private final EventPublisher eventPublisher;
+
+ public ExtensionService(CrudService crudService, EventPublisher eventPublisher) {
+ this.crudService = crudService;
+ this.eventPublisher = eventPublisher;
+ }
+}
+```
+
+### 3. **Publish Extension Events**
+```java
+@Service
+public class FileUploadService {
+
+ private final ApplicationEventPublisher eventPublisher;
+
+ public void uploadFile(EntityFile file) {
+ // ... upload logic
+ eventPublisher.publishEvent(new FileUploadedEvent(file));
+ }
+}
+```
+
+### 4. **Provide Extension Points**
+```java
+public interface FileStorageProvider {
+ void save(EntityFile file, InputStream inputStream);
+ InputStream load(EntityFile file);
+}
+
+@Component
+public class S3FileStorageProvider implements FileStorageProvider {
+ // AWS S3 implementation
+}
+```
+
+---
+
+## Summary
+
+DynamiaTools extensions provide:
+
+- **SaaS**: Multi-tenancy and account management
+- **Entity Files**: File attachment with S3 support
+- **Email & SMS**: Professional messaging
+- **Dashboard**: Executive dashboards
+- **Reports**: Advanced reporting and analytics
+- **Finance**: Financial calculations
+- **File Importer**: Bulk data import
+- **Security**: Authentication and authorization
+- **HTTP Functions**: Serverless-style functions
+
+Each extension follows consistent patterns and integrates seamlessly with core modules.
+
+---
+
+Next: Read [Development Patterns](./DEVELOPMENT_PATTERNS.md) to learn best practices for working with extensions and core modules.
+
diff --git a/docs/backend/README.md b/docs/backend/README.md
new file mode 100644
index 00000000..c50b7271
--- /dev/null
+++ b/docs/backend/README.md
@@ -0,0 +1,251 @@
+# Backend Developer Documentation
+
+Welcome to the DynamiaTools Backend Developer Documentation. This comprehensive guide will help you understand the architecture, core concepts, and best practices for building enterprise applications with DynamiaTools.
+
+## 📚 Documentation Overview
+
+This documentation is organized into the following sections:
+
+### Getting Started
+- **[Architecture Overview](./ARCHITECTURE.md)** - Understand the layered architecture and design principles of DynamiaTools
+- **[Core Modules Reference](./CORE_MODULES.md)** - Detailed explanation of each platform core module
+- **[Extensions Guide](./EXTENSIONS.md)** - Pre-built enterprise extensions and their purposes
+
+### Development & Best Practices
+- **[Development Patterns](./DEVELOPMENT_PATTERNS.md)** - Common patterns, anti-patterns, and best practices
+- **[Advanced Topics](./ADVANCED_TOPICS.md)** - Spring integration, modularity, custom extensions, and security
+- **[Examples & Integration](./EXAMPLES.md)** - Code examples for common tasks and real-world scenarios
+
+## 🎯 Quick Navigation
+
+### For New Developers
+Start with these documents in order:
+1. Read [Architecture Overview](./ARCHITECTURE.md) - Understand the big picture (15 min)
+2. Read [Core Modules Reference](./CORE_MODULES.md) - Learn the key modules (20 min)
+3. Explore [Examples & Integration](./EXAMPLES.md) - See code in action (15 min)
+4. Reference [Development Patterns](./DEVELOPMENT_PATTERNS.md) as needed (ongoing)
+
+**Estimated time to get started: ~50 minutes**
+
+### For Architects & Tech Leads
+- [Architecture Overview](./ARCHITECTURE.md) - Design decisions and layer organization
+- [Advanced Topics](./ADVANCED_TOPICS.md) - Extensibility, modularity, and enterprise patterns
+- [Extensions Guide](./EXTENSIONS.md) - Built-in modules and integration points
+
+### For Extension Developers
+- [Extensions Guide](./EXTENSIONS.md) - Understand existing extensions
+- [Development Patterns](./DEVELOPMENT_PATTERNS.md) - ModuleProvider pattern, custom extensions
+- [Advanced Topics](./ADVANCED_TOPICS.md) - Spring integration and bean lifecycle
+- [Examples & Integration](./EXAMPLES.md) - Code examples for extensions
+
+## 🏗️ Project Structure
+
+```
+DynamiaTools/
+├── framework/ # This repository
+│ ├── platform/
+│ │ ├── app/ # Application bootstrap & metadata
+│ │ ├── core/ # Core modules
+│ │ │ ├── commons/ # Utilities and common classes
+│ │ │ ├── domain/ # Domain model abstractions
+│ │ │ ├── domain-jpa/ # JPA implementations
+│ │ │ ├── crud/ # CRUD framework
+│ │ │ ├── navigation/ # Navigation & modules system
+│ │ │ ├── actions/ # Actions framework
+│ │ │ ├── integration/ # Spring integration
+│ │ │ ├── web/ # Web utilities
+│ │ │ ├── reports/ # Reporting framework
+│ │ │ ├── templates/ # Template system
+│ │ │ └── viewers/ # View rendering system
+│ │ ├── ui/ # UI components & themes
+│ │ │ ├── zk/ # ZK framework integration
+│ │ │ └── ui-shared/ # Shared UI components
+│ │ ├── packages/ # TypeScript/JavaScript packages
+│ │ └── starters/ # Spring Boot starters
+│ ├── extensions/ # Enterprise extensions
+│ │ ├── saas/ # Multi-tenancy module
+│ │ ├── entity-files/ # File attachment system
+│ │ ├── email-sms/ # Communication module
+│ │ ├── dashboard/ # Dashboard widgets
+│ │ ├── reports/ # Advanced reporting
+│ │ ├── finances/ # Financial calculations
+│ │ ├── file-importer/ # Data import
+│ │ ├── security/ # Auth & authorization
+│ │ └── http-functions/ # HTTP-based functions
+│ └── themes/ # UI themes
+└── website/ # https://github.com/dynamiatools/website/
+```
+
+## 🔑 Key Concepts
+
+### Modules
+Modular building blocks that encapsulate features and functionality. Each module can define pages, navigation, and actions.
+
+### Entities
+Domain model classes mapped to database tables using JPA. Automatically get CRUD operations.
+
+### CRUD Operations
+Create, Read, Update, Delete operations performed through the `CrudService` abstraction.
+
+### View Descriptors
+YAML files that define how entities are displayed in the UI. Support for forms, tables, trees, and more.
+
+### Actions
+Reusable components that encapsulate behaviors triggered by user interactions (button clicks, menu selections, etc.).
+
+### View Renderers
+Components responsible for rendering views. Default implementation uses ZK, but you can create custom renderers.
+
+## 📊 Technology Stack
+
+- **Java 25** - Latest Java features and performance improvements
+- **Spring Boot 4** - Modern Spring ecosystem
+- **Spring Data JPA** - Database abstraction and ORM
+- **ZK 10** - Enterprise-grade UI framework (default)
+- **Maven 3.9+** - Build and dependency management
+- **H2/PostgreSQL/MySQL** - Database support
+
+## 🚀 Getting Started with Your First Application
+
+### 1. Create a Spring Boot Project
+
+Visit [start.spring.io](https://start.spring.io) and select:
+- Java 25
+- Spring Boot 4.x
+- Spring Web
+- Spring Data JPA
+- Your preferred database (H2, PostgreSQL, MySQL, etc.)
+
+### 2. Add DynamiaTools Dependencies
+
+```xml
+
+
+ tools.dynamia
+ tools.dynamia.app
+ 26.3.2
+
+
+
+
+ tools.dynamia
+ tools.dynamia.zk
+ 26.3.2
+
+
+
+
+ tools.dynamia
+ tools.dynamia.domain.jpa
+ 26.3.2
+
+```
+
+### 3. Enable DynamiaTools
+
+```java
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import tools.dynamia.app.EnableDynamiaTools;
+
+@SpringBootApplication
+@EnableDynamiaTools
+public class MyApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(MyApplication.class, args);
+ }
+}
+```
+
+### 4. Create Your First Entity
+
+```java
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+
+@Entity
+public class Contact {
+ @Id
+ @GeneratedValue
+ private Long id;
+
+ private String name;
+ private String email;
+ private String phone;
+
+ // Getters and setters
+}
+```
+
+### 5. Create a Module Provider
+
+```java
+import org.springframework.stereotype.Component;
+import tools.dynamia.navigation.Module;
+import tools.dynamia.navigation.ModuleProvider;
+import tools.dynamia.crud.CrudPage;
+
+@Component
+public class ContactModuleProvider implements ModuleProvider {
+ @Override
+ public Module getModule() {
+ Module module = new Module("crm", "CRM");
+ module.addPage(new CrudPage("contacts", "Contacts", Contact.class));
+ return module;
+ }
+}
+```
+
+### 6. Run Your Application
+
+```bash
+mvn spring-boot:run
+```
+
+Visit `http://localhost:8080` and you'll see a fully functional CRUD interface!
+
+## 📖 Recommended Reading Order
+
+1. **[ARCHITECTURE.md](./ARCHITECTURE.md)** - Understand the foundational design
+2. **[CORE_MODULES.md](./CORE_MODULES.md)** - Learn each module's responsibility
+3. **[DEVELOPMENT_PATTERNS.md](./DEVELOPMENT_PATTERNS.md)** - Common patterns and best practices
+4. **[EXTENSIONS.md](./EXTENSIONS.md)** - Explore pre-built enterprise features
+5. **[EXAMPLES.md](./EXAMPLES.md)** - Complete code examples and real-world scenarios
+6. **[ADVANCED_TOPICS.md](./ADVANCED_TOPICS.md)** - Spring integration, security, caching, and microservices
+
+
+## 🔗 Additional Resources
+
+- **Main Documentation**: https://dynamia.tools
+- **GitHub Repository**: https://github.com/dynamiatools/framework
+- **Maven Central**: https://search.maven.org/search?q=tools.dynamia
+- **Issue Tracker**: https://github.com/dynamiatools/framework/issues
+- **Discussions**: https://github.com/dynamiatools/framework/discussions
+
+## ❓ Common Questions
+
+**Q: What Java version is required?**
+A: Java 25 or higher. The framework uses latest Java features for performance and modern syntax.
+
+**Q: Can I use DynamiaTools with other frameworks?**
+A: Yes! DynamiaTools is built on Spring Boot, so it integrates seamlessly with any Spring-compatible library.
+
+**Q: Do I have to use ZK for the UI?**
+A: No. ZK is the default renderer, but you can create custom view renderers for React, Vue, Angular, or any framework.
+
+**Q: How do I add custom validation?**
+A: Use the `Validator` interface and `@InstallValidator` annotation. See [Development Patterns](./DEVELOPMENT_PATTERNS.md).
+
+**Q: Can I use DynamiaTools for microservices?**
+A: Yes! Each module can be packaged as a JAR and deployed independently. See [Advanced Topics](./ADVANCED_TOPICS.md).
+
+**Q: Where can I get help?**
+A: Check the documentation, browse GitHub discussions, or file an issue on GitHub.
+
+## 📝 CalVer Versioning
+
+DynamiaTools uses Calendar Versioning (CalVer) with format `YY.MM.MINOR`:
+- **26.3.2** = Year 26, Month 03 (March), Release 02
+- All platform components share the same version
+- No more dependency version mismatches!
diff --git a/docs/frontend/API_CLIENT_STANDARDS.md b/docs/frontend/API_CLIENT_STANDARDS.md
new file mode 100644
index 00000000..e85c4aab
--- /dev/null
+++ b/docs/frontend/API_CLIENT_STANDARDS.md
@@ -0,0 +1,437 @@
+# API Client Standards & Architecture
+
+## Overview
+
+This document establishes the standards and best practices for all API calls across the **Dynamia Tools** SDK packages. All backend communication must flow through `DynamiaClient` and its `HttpClient` to ensure consistency in:
+
+- **Authentication** (Bearer tokens, Basic Auth, Cookies)
+- **Error Handling** (uniform `DynamiaApiError` exceptions)
+- **Fetch Implementation** (centralized, testable)
+- **Request/Response Types** (TypeScript-first)
+- **URL Building** (baseUrl, path normalization, query params)
+
+---
+
+## Architecture
+
+### Core: `@dynamia-tools/sdk`
+
+**Location:** `platform/packages/sdk`
+
+The root client for all Dynamia Platform REST APIs. Exports:
+
+1. **`HttpClient`** (`src/http.ts`)
+ - Wraps fetch with auth headers
+ - Handles URL building with baseUrl + path + query params
+ - Provides `get()`, `post()`, `put()`, `delete()`, `url()`
+ - All errors become `DynamiaApiError`
+
+2. **`DynamiaClient`** (`src/client.ts`)
+ - Holds configured `HttpClient` instance (public readonly `http` property)
+ - Exposes sub-APIs:
+ - `metadata: MetadataApi` — app metadata, navigation, views
+ - `actions: ActionsApi` — execute global/entity actions
+ - `schedule: ScheduleApi` — scheduled tasks
+ - `crud(virtualPath)` → `CrudResourceApi` — navigation-based CRUD
+ - `crudService(className)` → `CrudServiceApi` — service-based CRUD
+
+3. **API Classes** (`src/metadata/*`, `src/cruds/*`, `src/schedule/*`)
+ - Accept `HttpClient` in constructor
+ - Call only `this.http.get()`, `this.http.post()`, etc.
+ - Return typed promises
+ - Example: `MetadataApi.getApp()` → `Promise`
+
+### Extension SDKs
+
+All extension SDKs follow the **same pattern**:
+
+#### `@dynamia-tools/reports-sdk`
+
+**Location:** `extensions/reports/packages/reports-sdk`
+
+```typescript
+// src/api.ts
+export class ReportsApi {
+ constructor(http: HttpClient) { /* ... */ }
+ list(): Promise { /* uses this.http.get() */ }
+ post(group, endpoint, filters?): Promise { /* uses this.http.post() */ }
+}
+```
+
+**Usage:**
+```typescript
+const client = new DynamiaClient({ baseUrl: '...', token: '...' });
+const reports = new ReportsApi(client.http);
+const list = await reports.list();
+```
+
+#### `@dynamia-tools/saas-sdk`
+
+**Location:** `extensions/saas/packages/saas-sdk`
+
+Identical pattern:
+```typescript
+export class SaasApi {
+ constructor(http: HttpClient) { /* ... */ }
+ getAccount(uuid: string): Promise { /* */ }
+}
+```
+
+#### `@dynamia-tools/files-sdk`
+
+**Location:** `extensions/entity-files/packages/files-sdk`
+
+Identical pattern:
+```typescript
+export class FilesApi {
+ constructor(http: HttpClient) { /* ... */ }
+ download(file: string, uuid: string): Promise { /* */ }
+ getUrl(file: string, uuid: string): string { /* */ }
+}
+```
+
+### UI Packages
+
+#### `@dynamia-tools/ui-core`
+
+**Location:** `platform/packages/ui-core`
+
+**Purpose:** Framework-agnostic view, action, and form classes.
+
+**API call pattern:**
+- Views/Actions **do NOT directly call APIs**
+- They accept injected data via loaders/callbacks
+- Example: `DataSetView.setLoader(loader)` where `loader` is an async callback
+- Caller supplies the actual API calls (usually from Vue composables)
+
+**Example:**
+```typescript
+// ui-core: Define the view
+const view = new TableView(descriptor);
+view.setLoader(async (params) => {
+ // Caller supplies the loader
+ return {
+ rows: [...],
+ pagination: { page: 1, ... }
+ };
+});
+```
+
+#### `@dynamia-tools/vue`
+
+**Location:** `platform/packages/vue`
+
+**Purpose:** Vue 3 integration + composables + components.
+
+**API call pattern:**
+1. **`useDynamiaClient()`** — Composable to inject the global `DynamiaClient` provided by the `DynamiaVue` plugin
+
+ ```typescript
+ export const DYNAMIA_CLIENT_KEY: InjectionKey = Symbol('DynamiaClient');
+
+ export function useDynamiaClient(): DynamiaClient | null {
+ return inject(DYNAMIA_CLIENT_KEY, null);
+ }
+ ```
+
+2. **Composables** — Accept custom loaders or use the injected client
+ - `useNavigation(client)` — Fetches via `client.metadata.getNavigation()`
+ - `useTable(options)` — Accepts optional `loader` callback
+ - `useCrud(options)` — Accepts optional `loader` / `onSave` / `onDelete`
+ - `useEntityPicker(options)` — Accepts optional `searcher` callback
+
+3. **Plugin** — Registers the global client
+ ```typescript
+ app.use(DynamiaVue, { client: new DynamiaClient({...}) });
+ ```
+
+4. **Views/Renderers** — Call composables; composables wire loaders; loaders use `client.http` or extension APIs
+
+---
+
+## Standard Patterns
+
+### ✅ Correct: Extension SDK with Shared HttpClient
+
+```typescript
+// ✅ CORRECT: extension SDKs always accept HttpClient in constructor
+
+// In extension SDK (e.g., @dynamia-tools/reports-sdk/src/api.ts)
+export class ReportsApi {
+ private readonly http: HttpClient;
+ constructor(http: HttpClient) { this.http = http; }
+ list(): Promise { return this.http.get('/api/reports'); }
+}
+
+// In application code
+const client = new DynamiaClient({ baseUrl: 'https://...', token: '...' });
+const reports = new ReportsApi(client.http); // ← Reuse the same HttpClient
+const list = await reports.list();
+```
+
+**Why:** Single auth, fetch, error handling; all requests logged/traced consistently.
+
+---
+
+### ✅ Correct: Vue Composable Using Injected Client
+
+```typescript
+// ✅ CORRECT: useNavigation accepts DynamiaClient
+
+export function useNavigation(client: DynamiaClient) {
+ async function loadNavigation() {
+ const tree = await client.metadata.getNavigation();
+ // ...
+ }
+}
+
+// In component
+const client = useDynamiaClient();
+const { nodes, currentPage } = useNavigation(client!);
+```
+
+**Why:** Client flows through a single injection point; all calls originate from one configured instance.
+
+---
+
+### ✅ Correct: Custom Loader in Vue Composable
+
+```typescript
+// ✅ CORRECT: useCrud accepts optional loader callback
+
+export interface UseCrudOptions {
+ loader?: (params: Record) => Promise<{ rows: any[]; pagination: any }>;
+}
+
+export function useCrud(options: UseCrudOptions) {
+ // Caller supplies the loader with their own API logic
+ if (options.loader) {
+ view.setLoader(options.loader);
+ }
+}
+
+// In component
+const client = useDynamiaClient();
+const { view } = useCrud({
+ descriptor,
+ loader: async (params) => {
+ const data = await client!.crud('books').findAll(params);
+ return { rows: data.content, pagination: {...} };
+ },
+});
+```
+
+**Why:** Composables are reusable; caller decides where data comes from.
+
+---
+
+### ❌ Incorrect: Direct fetch in Extension SDK
+
+```typescript
+// ❌ WRONG: Don't use fetch directly
+export class ReportsApi {
+ async list() {
+ const res = await fetch('/api/reports'); // ❌ No auth headers, error handling
+ return res.json();
+ }
+}
+```
+
+**Why:** Bypasses central auth, loses error handling, can't be mocked in tests.
+
+---
+
+### ❌ Incorrect: Direct fetch in View/Composable
+
+```typescript
+// ❌ WRONG: Don't use fetch in ui-core Views
+export class TableView {
+ async load() {
+ const res = await fetch(`/api/books?page=${this.page}`); // ❌ No auth
+ this.rows = await res.json();
+ }
+}
+```
+
+**Why:** Views should not know about HTTP; data comes from loaders (injected dependency).
+
+---
+
+### ❌ Incorrect: Hardcoded Fetch in Composable
+
+```typescript
+// ❌ WRONG: Direct fetch in composable
+export function useBooks() {
+ async function load() {
+ const res = await fetch('/api/books'); // ❌ No auth, no error handling
+ return res.json();
+ }
+}
+```
+
+**Why:** Should use `useDynamiaClient()` → `client.crud('books').findAll()`.
+
+---
+
+## File Structure & Conventions
+
+### Extension SDK Package (template)
+
+```
+extensions/MY-EXT/packages/MY-SDK/
+├── src/
+│ ├── api.ts # Main API class, accepts HttpClient
+│ ├── types.ts # DTO / response types
+│ └── index.ts # Exports
+├── test/
+│ ├── MY-api.test.ts # Vitest tests
+│ └── helpers.ts # mockFetch, makeHttpClient
+├── README.md # Usage docs
+├── package.json # Peer dep: @dynamia-tools/sdk
+└── vite.config.ts
+```
+
+### API Class Conventions
+
+```typescript
+// ✅ PATTERN FOR ALL API CLASSES
+
+import type { HttpClient } from '@dynamia-tools/sdk';
+
+/**
+ * API description.
+ * Base path: /api/resource
+ */
+export class MyApi {
+ private readonly http: HttpClient;
+
+ constructor(http: HttpClient) {
+ this.http = http;
+ }
+
+ /** GET /api/resource — Description */
+ async getAll(): Promise {
+ return this.http.get('/api/resource');
+ }
+
+ /** POST /api/resource — Description */
+ async create(data: Partial): Promise {
+ return this.http.post('/api/resource', data);
+ }
+
+ /** PUT /api/resource/{id} — Description */
+ async update(id: string, data: Partial): Promise {
+ return this.http.put(`/api/resource/${id}`, data);
+ }
+
+ /** DELETE /api/resource/{id} — Description */
+ async delete(id: string): Promise {
+ return this.http.delete(`/api/resource/${id}`);
+ }
+}
+```
+
+---
+
+## Testing
+
+### ✅ Correct: Mock HttpClient in Tests
+
+```typescript
+// ✅ CORRECT: Use test helpers that create a mock HttpClient
+
+import { vi } from 'vitest';
+import { DynamiaClient, HttpClient } from '@dynamia-tools/sdk';
+
+export function mockFetch(status: number, body: unknown) {
+ return vi.fn().mockResolvedValue({
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(body),
+ text: () => Promise.resolve(String(body)),
+ } as unknown as Response);
+}
+
+export function makeHttpClient(fetchMock) {
+ const client = new DynamiaClient({
+ baseUrl: 'https://api.example.com',
+ token: 'test-token',
+ fetch: fetchMock,
+ });
+ return client.http;
+}
+
+// In test
+const fetch = mockFetch(200, [{ id: 1, name: 'Item' }]);
+const http = makeHttpClient(fetch);
+const api = new MyApi(http);
+const result = await api.getAll();
+expect(result).toEqual([{ id: 1, name: 'Item' }]);
+```
+
+**Why:** Tests the real API class logic without network; can verify URL and method.
+
+---
+
+## Audit Summary
+
+### ✅ Compliant Packages
+
+| Package | Location | Pattern | Status |
+|---------|----------|---------|--------|
+| `@dynamia-tools/sdk` | `platform/packages/sdk` | Central HttpClient + sub-APIs | ✅ |
+| `@dynamia-tools/reports-sdk` | `extensions/reports/packages/reports-sdk` | Accepts HttpClient in API class | ✅ |
+| `@dynamia-tools/saas-sdk` | `extensions/saas/packages/saas-sdk` | Accepts HttpClient in API class | ✅ |
+| `@dynamia-tools/files-sdk` | `extensions/entity-files/packages/files-sdk` | Accepts HttpClient in API class | ✅ |
+| `@dynamia-tools/ui-core` | `platform/packages/ui-core` | No direct API calls; uses loaders | ✅ |
+| `@dynamia-tools/vue` | `platform/packages/vue` | Uses `useDynamiaClient()` injection | ✅ |
+
+### 📋 Key Findings
+
+1. **No direct fetch/axios calls** detected in any package.
+2. **Extension SDKs** all follow the HttpClient pattern correctly.
+3. **Vue composables** properly inject DynamiaClient and pass it to sub-APIs.
+4. **ui-core views** correctly delegate API calls to loaders (no direct HTTP).
+
+---
+
+## Recommendations for New Packages
+
+### When Creating a New Extension SDK
+
+1. Create the API class with `HttpClient` in constructor:
+ ```typescript
+ export class MyNewApi {
+ constructor(http: HttpClient) { this.http = http; }
+ // methods using this.http.get/post/etc.
+ }
+ ```
+
+2. Add README with usage example:
+ ```typescript
+ import { DynamiaClient } from '@dynamia-tools/sdk';
+ import { MyNewApi } from '@dynamia-tools/my-new-sdk';
+
+ const client = new DynamiaClient({ baseUrl: '...', token: '...' });
+ const api = new MyNewApi(client.http);
+ ```
+
+3. Add tests with mockFetch helper:
+ ```typescript
+ const http = makeHttpClient(mockFetch(200, testData));
+ const api = new MyNewApi(http);
+ ```
+
+4. Declare peer dependency on `@dynamia-tools/sdk` in package.json.
+
+---
+
+## References
+
+- **SDK README:** `platform/packages/sdk/README.md`
+- **Extension SDK Examples:**
+ - Reports: `extensions/reports/packages/reports-sdk/README.md`
+ - SaaS: `extensions/saas/packages/saas-sdk/README.md`
+ - Files: `extensions/entity-files/packages/files-sdk/README.md`
+- **Vue Plugin Usage:** `examples/demo-vue-books/src/App.vue`
+
diff --git a/docs/frontend/COHERENCE_RECOMMENDATIONS.md b/docs/frontend/COHERENCE_RECOMMENDATIONS.md
new file mode 100644
index 00000000..357069fc
--- /dev/null
+++ b/docs/frontend/COHERENCE_RECOMMENDATIONS.md
@@ -0,0 +1,458 @@
+# Coherence & Implementation Recommendations
+
+**Date:** March 19, 2026
+
+This document summarizes findings from the comprehensive audit of API client patterns across the Dynamia Tools framework and provides actionable recommendations for maintaining coherence and extending the architecture.
+
+---
+
+## Executive Summary
+
+✅ **All packages are coherent and compliant** with the centralized `DynamiaClient` pattern.
+
+**Key Finding:** There is **zero inconsistency** in how backend calls are made. The pattern is uniform across:
+- Core SDK (`@dynamia-tools/sdk`)
+- Extension SDKs (Reports, SaaS, Files)
+- UI Core (`@dynamia-tools/ui-core`)
+- Vue Integration (`@dynamia-tools/vue`)
+
+---
+
+## 1. Current State: Strengths
+
+### 1.1 Centralized HTTP Management
+
+**What works well:**
+- Single `HttpClient` instance manages all fetch calls
+- Uniform auth handling (Bearer tokens, Basic Auth, Cookies)
+- Consistent error handling via `DynamiaApiError`
+- Central URL building with baseUrl + path normalization
+- Single point for logging, retry logic, interceptors
+
+**Impact:** Changes to authentication or error handling propagate automatically to all SDKs.
+
+### 1.2 Extension SDK Pattern
+
+**What works well:**
+- All extension SDKs accept `HttpClient` in constructor
+- No direct fetch, axios, or HTTP library usage
+- Consistent test helpers (mockFetch, makeHttpClient)
+- Proper TypeScript typing throughout
+- READMEs clearly document the pattern
+
+**Impact:** New extension SDKs can be added without risk of divergent patterns.
+
+### 1.3 View Separation of Concerns
+
+**What works well:**
+- `@dynamia-tools/ui-core` views don't call APIs
+- Data is injected via callbacks (loaders, searchers)
+- Framework-agnostic design (reusable in Vue, React, Svelte, etc.)
+- Views are easily testable with mock loaders
+
+**Impact:** UI logic is decoupled from HTTP logic; both can evolve independently.
+
+### 1.4 Vue Integration
+
+**What works well:**
+- `useDynamiaClient()` provides single injection point
+- Composables accept injected client or custom callbacks
+- Plugin pattern is clean and Vue-idiomatic
+- Real-world demo shows working examples
+
+**Impact:** Components are not tightly coupled to specific APIs; loaders are swappable.
+
+---
+
+## 2. Identified Gaps
+
+While the current architecture is coherent, there are **opportunities for improvement**:
+
+### 2.1 Documentation Gap: Vue Patterns
+
+**Issue:**
+- Core SDK and extension SDKs have clear READMEs
+- Vue package lacks a comprehensive integration guide
+
+**Impact:**
+- Developers may not know when to use `useDynamiaClient()` vs. custom loaders
+- Testing patterns for composables not well-documented
+- Real-world examples limited to one demo app
+
+**Recommendation:** ✅ **IMPLEMENTED** — Created `platform/packages/vue/GUIDE.md` (see output above)
+
+### 2.2 Documentation Gap: Extension SDK Creation
+
+**Issue:**
+- No formal template for new extension SDKs
+- Each new SDK is created "from scratch"
+- Risk of pattern drift as projects add new extensions
+
+**Impact:**
+- Inconsistent package.json configurations
+- Variable test coverage
+- Unclear conventions for API class methods
+
+**Recommendation:** ✅ **IMPLEMENTED** — Created `EXTENSION_SDK_TEMPLATE.md` (see output above)
+
+### 2.3 Lack of Formal Standards Document
+
+**Issue:**
+- Best practices exist but not documented in one place
+- New developers must infer patterns from existing code
+
+**Impact:**
+- Onboarding time increased
+- Pattern violations possible in future contributions
+
+**Recommendation:** ✅ **IMPLEMENTED** — Created `API_CLIENT_STANDARDS.md` (see output above)
+
+### 2.4 Error Handling Documentation
+
+**Issue:**
+- `DynamiaApiError` exists but not all packages document error handling
+- Vue composables don't show error patterns
+
+**Impact:**
+- Inconsistent error handling in applications
+
+**Recommendation:** Add error handling examples to Vue GUIDE and extension SDK template (see templates above)
+
+### 2.5 Testing Patterns Variation
+
+**Issue:**
+- Each extension SDK has its own test helpers (mockFetch, makeHttpClient)
+- Shared test utilities not centralized
+
+**Current:** Helpers are duplicated but identical in:
+- `platform/packages/sdk/test/helpers.ts`
+- `extensions/reports/packages/reports-sdk/test/helpers.ts`
+- `extensions/saas/packages/saas-sdk/test/helpers.ts`
+- `extensions/entity-files/packages/files-sdk/test/helpers.ts`
+
+**Impact:** DRY principle violated; future bug fixes must be propagated manually
+
+**Recommendation:** Consider creating a `@dynamia-tools/test-utils` package
+
+---
+
+## 3. Recommendations for Coherence
+
+### 3.1 Create Shared Test Utilities Package
+
+**Proposal:**
+```
+platform/packages/test-utils/
+├── src/
+│ ├── http-mocks.ts # mockFetch, makeHttpClient
+│ ├── client-mocks.ts # makeMockClient
+│ └── index.ts # Exports
+├── package.json
+├── README.md
+└── ...
+```
+
+**Benefit:**
+- Single source of truth for test helpers
+- Consistent across all SDKs
+- Easy to add new mock utilities (interceptors, retry logic, etc.)
+
+**Implementation:**
+```typescript
+// @dynamia-tools/test-utils
+export { mockFetch, makeHttpClient } from './http-mocks.js';
+
+// In any SDK test
+import { mockFetch, makeHttpClient } from '@dynamia-tools/test-utils';
+```
+
+### 3.2 Add CI Integration Tests
+
+**Proposal:** Add GitHub Actions workflow to verify:
+1. No `fetch()` calls outside of `HttpClient`
+2. All API classes accept `HttpClient`
+3. All packages use mocks in tests
+4. All extension SDKs follow naming conventions
+
+**Example:**
+```yaml
+# .github/workflows/api-coherence.yml
+name: API Coherence Check
+
+on: [pull_request]
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Check for forbidden HTTP libraries
+ run: |
+ grep -r "fetch(" platform/packages/*/src --include="*.ts" && exit 1
+ grep -r "axios" platform/packages/*/src --include="*.ts" && exit 1
+ echo "✅ No forbidden HTTP libraries found"
+ - name: Check extension SDKs
+ run: |
+ for dir in extensions/*/packages/*-sdk; do
+ if [ ! -f "$dir/src/api.ts" ]; then
+ echo "❌ Missing src/api.ts in $dir"
+ exit 1
+ fi
+ done
+ echo "✅ All extension SDKs follow structure"
+```
+
+### 3.3 Enhance Error Handling Documentation
+
+**Proposal:** Add to every SDK README a section:
+
+```markdown
+## Error Handling
+
+All API errors are thrown as \`DynamiaApiError\` (from \`@dynamia-tools/sdk\`):
+
+\`\`\`ts
+try {
+ const data = await api.getData();
+} catch (err) {
+ if (err instanceof DynamiaApiError) {
+ console.error(\`[\${err.status}] \${err.message}\`);
+ console.log(err.body); // Full error response
+ } else {
+ throw err; // Other error
+ }
+}
+\`\`\`
+```
+
+### 3.4 Create Contribution Checklist
+
+**Proposal:** `framework/CONTRIBUTING_API_CLIENTS.md`
+
+Checklist for new extensions/SDKs:
+- [ ] Uses centralized `DynamiaClient` pattern
+- [ ] API class accepts `HttpClient` in constructor
+- [ ] No direct `fetch()`, `axios`, or similar
+- [ ] All responses are TypeScript typed
+- [ ] Tests use `mockFetch` helper
+- [ ] README documents usage with `DynamiaClient`
+- [ ] Error handling example included
+- [ ] Follows naming conventions
+- [ ] ESLint passes (no forbidden imports)
+
+### 3.5 Document Authorization Patterns
+
+**Proposal:** Add to core SDK documentation:
+
+```markdown
+## Authorization Patterns for Extension SDKs
+
+All extension SDKs inherit auth from the core client. Examples:
+
+### Bearer Token (JWT)
+\`\`\`ts
+const client = new DynamiaClient({
+ baseUrl: '...',
+ token: 'eyJhbGciOiJIUzI1NiJ9...',
+});
+\`\`\`
+
+### Basic Auth
+\`\`\`ts
+const client = new DynamiaClient({
+ baseUrl: '...',
+ username: 'admin',
+ password: 'secret',
+});
+\`\`\`
+
+### Session Cookies
+\`\`\`ts
+const client = new DynamiaClient({
+ baseUrl: '...',
+ withCredentials: true,
+});
+\`\`\`
+
+Extension SDKs automatically use the configured auth:
+\`\`\`ts
+const api = new MyExtensionApi(client.http); // ← Uses client's auth
+\`\`\`
+```
+
+---
+
+## 4. Implementation Roadmap
+
+### Phase 1: Documentation (COMPLETED ✅)
+- [x] Create `API_CLIENT_STANDARDS.md` — Comprehensive standards
+- [x] Create `API_CLIENT_AUDIT_REPORT.md` — Detailed audit findings
+- [x] Create `platform/packages/vue/GUIDE.md` — Vue integration patterns
+- [x] Create `EXTENSION_SDK_TEMPLATE.md` — Template for new SDKs
+
+### Phase 2: Tooling (RECOMMENDED)
+- [ ] Create `@dynamia-tools/test-utils` package
+- [ ] Add API coherence CI checks
+- [ ] Create `CONTRIBUTING_API_CLIENTS.md` checklist
+
+### Phase 3: Evolution (FUTURE)
+- [ ] Request interceptors/middleware in `HttpClient`
+- [ ] Retry logic for failed requests
+- [ ] Request/response logging configuration
+- [ ] Plugin system for extending APIs
+
+---
+
+## 5. Pattern Consistency Checklist
+
+Use this checklist when reviewing PRs or creating new packages:
+
+### ✅ For Extension SDK Packages
+
+- [ ] API class accepts `HttpClient` in constructor
+- [ ] All HTTP methods use `this.http.get()`, `this.http.post()`, etc.
+- [ ] No direct `fetch()` or third-party HTTP library
+- [ ] All responses are TypeScript typed (DTO interfaces)
+- [ ] Tests use `mockFetch` + `makeHttpClient` helpers
+- [ ] README shows usage with `new MyApi(client.http)`
+- [ ] README documents error handling with `DynamiaApiError`
+- [ ] package.json declares peer dependency on `@dynamia-tools/sdk`
+- [ ] Files in `src/`, tests in `test/` directories
+- [ ] Follows `@dynamia-tools/{extension}-sdk` naming
+
+### ✅ For Vue Packages/Composables
+
+- [ ] Composables accept `DynamiaClient` or custom callbacks
+- [ ] `useDynamiaClient()` used for client injection
+- [ ] No direct `fetch()` or third-party HTTP library
+- [ ] Loader/searcher callbacks are tested with mocks
+- [ ] Components don't call APIs directly (use composables)
+- [ ] Plugin is registered at app initialization
+
+### ✅ For ui-core Views
+
+- [ ] Views don't call `HttpClient` directly
+- [ ] Data is injected via callbacks (loaders, searchers)
+- [ ] Tests mock the callbacks, not network
+- [ ] Views are framework-agnostic
+
+---
+
+## 6. Future Enhancements
+
+### 6.1 Request Interceptors
+
+**Current:** `HttpClient` doesn't support interceptors.
+
+**Future Enhancement:**
+```typescript
+const client = new DynamiaClient({ ... });
+client.http.interceptors.request.use(req => {
+ console.log(`[HTTP] ${req.method} ${req.url}`);
+ return req;
+});
+```
+
+### 6.2 Retry Policy
+
+**Current:** No automatic retries.
+
+**Future Enhancement:**
+```typescript
+const client = new DynamiaClient({
+ ...,
+ retry: {
+ maxAttempts: 3,
+ backoffMultiplier: 2,
+ retryableStatuses: [408, 429, 500, 502, 503, 504],
+ },
+});
+```
+
+### 6.3 Cache Control
+
+**Current:** Only `MetadataApi` caches views.
+
+**Future Enhancement:**
+```typescript
+const client = new DynamiaClient({
+ ...,
+ cache: {
+ enabled: true,
+ ttl: 60000, // 60 seconds
+ policies: {
+ 'GET /api/metadata': 'long', // 1 hour
+ 'GET /api/crud': 'short', // 5 minutes
+ },
+ },
+});
+```
+
+---
+
+## 7. Conclusion
+
+The Dynamia Tools framework has achieved **excellent coherence** in API client patterns. All packages follow the centralized `DynamiaClient` paradigm with no deviations.
+
+### Key Takeaways
+
+1. **Current State:** ✅ Compliant and coherent
+2. **Documentation:** ✅ Enhanced with comprehensive guides
+3. **Template:** ✅ Provided for new extension SDKs
+4. **Next Steps:** Consider test utilities package and CI checks
+
+### Artifacts Delivered
+
+| Document | Location | Purpose |
+|----------|----------|---------|
+| API Client Standards | `API_CLIENT_STANDARDS.md` | Comprehensive standards guide |
+| Audit Report | `API_CLIENT_AUDIT_REPORT.md` | Detailed findings and status |
+| Vue Guide | `platform/packages/vue/GUIDE.md` | Integration patterns & examples |
+| SDK Template | `EXTENSION_SDK_TEMPLATE.md` | Boilerplate for new SDKs |
+| This Document | `COHERENCE_RECOMMENDATIONS.md` | Future improvements & roadmap |
+
+---
+
+## Appendix: Quick Reference
+
+### When to use what
+
+| Scenario | Use This | Not This |
+|----------|----------|----------|
+| Call backend API | `new MyApi(client.http)` | Direct `fetch()` |
+| Fetch navigation | `client.metadata.getNavigation()` | Direct API call |
+| Load table data | Pass `loader` to `useTable()` | API call in component |
+| Handle auth | `DynamiaClient` config | Manual headers |
+| Test API | `mockFetch()` + `makeHttpClient()` | Network requests |
+| Test composable | Mock loader callback | Network requests |
+| Inject client | `useDynamiaClient()` | Props drilling |
+| Extension API | Accept `HttpClient` in constructor | Direct fetch |
+
+---
+
+## Appendix: File Locations
+
+```
+framework/
+├── API_CLIENT_STANDARDS.md (✅ Created)
+├── API_CLIENT_AUDIT_REPORT.md (✅ Created)
+├── COHERENCE_RECOMMENDATIONS.md (This file)
+├── EXTENSION_SDK_TEMPLATE.md (✅ Created)
+├── platform/
+│ └── packages/
+│ └── vue/
+│ └── GUIDE.md (✅ Created)
+└── extensions/
+ ├── reports/packages/reports-sdk/
+ │ └── README.md (Already exists)
+ ├── saas/packages/saas-sdk/
+ │ └── README.md (Already exists)
+ └── entity-files/packages/files-sdk/
+ └── README.md (Already exists)
+```
+
+---
+
+**Review Status:** Ready for Implementation
+**Last Updated:** March 19, 2026
+
diff --git a/docs/frontend/EXTENSION_SDK_TEMPLATE.md b/docs/frontend/EXTENSION_SDK_TEMPLATE.md
new file mode 100644
index 00000000..9714cdcf
--- /dev/null
+++ b/docs/frontend/EXTENSION_SDK_TEMPLATE.md
@@ -0,0 +1,610 @@
+# Extension SDK Template
+
+**Location:** This template should be used when creating new SDK packages for Dynamia Platform extensions.
+
+> **Quick Start:** Copy this entire directory structure and replace `MY_EXTENSION` with your extension name.
+
+---
+
+## Directory Structure
+
+```
+extensions/MY_EXTENSION/packages/MY_EXTENSION-sdk/
+├── src/
+│ ├── api.ts # Main API class
+│ ├── types.ts # TypeScript DTOs / response types
+│ ├── index.ts # Public exports
+│ └── ... # Additional API classes/utilities
+├── test/
+│ ├── MY_EXTENSION.test.ts # Tests for api.ts
+│ ├── helpers.ts # Mock utilities (mockFetch, makeHttpClient)
+│ └── ... # Additional tests
+├── README.md # User documentation
+├── package.json # Package metadata
+├── vite.config.ts # Vite configuration
+├── vitest.config.ts # Vitest configuration
+├── tsconfig.json # TypeScript configuration
+├── LICENSE # License (copy from framework root)
+└── .npmignore # NPM ignore patterns
+```
+
+---
+
+## Step-by-Step Creation Guide
+
+### 1. Create Directory
+
+```bash
+cd extensions/MY_EXTENSION/packages
+mkdir my_extension-sdk
+cd my_extension-sdk
+```
+
+### 2. Initialize package.json
+
+```json
+{
+ "name": "@dynamia-tools/my_extension-sdk",
+ "version": "1.0.0",
+ "description": "Official TypeScript / JavaScript client SDK for the Dynamia My Extension REST API.",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "tsc --outDir dist",
+ "test": "vitest",
+ "test:coverage": "vitest --coverage",
+ "lint": "eslint src test"
+ },
+ "keywords": [
+ "dynamia",
+ "sdk",
+ "api",
+ "my-extension"
+ ],
+ "author": "Dynamia Framework",
+ "license": "MIT",
+ "peerDependencies": {
+ "@dynamia-tools/sdk": "^1.0.0"
+ },
+ "devDependencies": {
+ "@dynamia-tools/sdk": "workspace:*",
+ "@types/node": "^20.0.0",
+ "typescript": "^5.0.0",
+ "vitest": "^1.0.0"
+ }
+}
+```
+
+### 3. TypeScript Configuration
+
+**tsconfig.json:**
+```json
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist"
+ },
+ "include": ["src"],
+ "exclude": ["test"]
+}
+```
+
+### 4. Vite & Vitest Configuration
+
+**vite.config.ts:**
+```typescript
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: './src/index.ts',
+ name: 'DynamiaMyExtensionSdk',
+ formats: ['es'],
+ },
+ rollupOptions: {
+ external: ['@dynamia-tools/sdk'],
+ },
+ },
+});
+```
+
+**vitest.config.ts:**
+```typescript
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ globals: true,
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ exclude: ['node_modules/', 'test/'],
+ },
+ },
+});
+```
+
+---
+
+## Template Files
+
+### src/api.ts
+
+```typescript
+// @dynamia-tools/my_extension-sdk — API class for My Extension
+
+import type { HttpClient } from '@dynamia-tools/sdk';
+import type { MyExtensionDTO, MyExtensionResponse } from './types.js';
+
+/**
+ * Access the My Extension REST API.
+ * Base path: /api/my_extension
+ */
+export class MyExtensionApi {
+ private readonly http: HttpClient;
+
+ /**
+ * @param http - HttpClient instance (typically from DynamiaClient.http)
+ */
+ constructor(http: HttpClient) {
+ this.http = http;
+ }
+
+ /**
+ * GET /api/my_extension — Get all items
+ *
+ * @returns Promise resolving to array of MyExtensionDTO
+ */
+ list(): Promise {
+ return this.http.get('/api/my_extension');
+ }
+
+ /**
+ * GET /api/my_extension/{id} — Get item by ID
+ *
+ * @param id - Item identifier
+ * @returns Promise resolving to MyExtensionDTO
+ */
+ getById(id: string | number): Promise {
+ return this.http.get(`/api/my_extension/${id}`);
+ }
+
+ /**
+ * POST /api/my_extension — Create new item
+ *
+ * @param data - Item data to create
+ * @returns Promise resolving to created MyExtensionDTO
+ */
+ create(data: Partial): Promise {
+ return this.http.post('/api/my_extension', data);
+ }
+
+ /**
+ * PUT /api/my_extension/{id} — Update item
+ *
+ * @param id - Item identifier
+ * @param data - Item data to update
+ * @returns Promise resolving to updated MyExtensionDTO
+ */
+ update(id: string | number, data: Partial): Promise {
+ return this.http.put(`/api/my_extension/${id}`, data);
+ }
+
+ /**
+ * DELETE /api/my_extension/{id} — Delete item
+ *
+ * @param id - Item identifier
+ * @returns Promise resolving when deleted
+ */
+ delete(id: string | number): Promise {
+ return this.http.delete(`/api/my_extension/${id}`);
+ }
+}
+```
+
+### src/types.ts
+
+```typescript
+/**
+ * Data Transfer Objects and types for My Extension API.
+ */
+
+/**
+ * Response from GET /api/my_extension endpoints
+ */
+export interface MyExtensionDTO {
+ id: string | number;
+ name: string;
+ description?: string;
+ createdAt: string;
+ updatedAt: string;
+ status: 'ACTIVE' | 'INACTIVE';
+}
+
+/**
+ * Generic response wrapper (if needed)
+ */
+export interface MyExtensionResponse {
+ data: T;
+ status: string;
+ statusCode: number;
+}
+```
+
+### src/index.ts
+
+```typescript
+/**
+ * @dynamia-tools/my_extension-sdk
+ * Official TypeScript / JavaScript client SDK for the Dynamia My Extension REST API.
+ */
+
+export { MyExtensionApi } from './api.js';
+export type { MyExtensionDTO, MyExtensionResponse } from './types.js';
+```
+
+### test/helpers.ts
+
+```typescript
+// Test utilities for mocking HTTP calls
+
+import { vi } from 'vitest';
+import { DynamiaClient, HttpClient } from '@dynamia-tools/sdk';
+
+/**
+ * Create a mocked fetch function that returns a predefined response.
+ *
+ * @param status - HTTP status code
+ * @param body - Response body
+ * @param contentType - Content-Type header (default: 'application/json')
+ * @returns Vitest mocked fetch function
+ */
+export function mockFetch(status: number, body: unknown, contentType = 'application/json') {
+ return vi.fn().mockResolvedValue({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? 'OK' : 'Error',
+ headers: {
+ get: (key: string) => (key === 'content-type' ? contentType : null),
+ },
+ json: () => Promise.resolve(body),
+ text: () => Promise.resolve(String(body)),
+ blob: () => Promise.resolve(new Blob()),
+ } as unknown as Response);
+}
+
+/**
+ * Create an HttpClient instance with a mocked fetch for testing.
+ *
+ * @param fetchMock - Mocked fetch function (from mockFetch)
+ * @returns HttpClient configured with mock fetch
+ */
+export function makeHttpClient(fetchMock: ReturnType): HttpClient {
+ const client = new DynamiaClient({
+ baseUrl: 'https://api.example.com',
+ token: 'test-token',
+ fetch: fetchMock,
+ });
+ return client.http as HttpClient;
+}
+```
+
+### test/MY_EXTENSION.test.ts
+
+```typescript
+// Tests for MyExtensionApi
+
+import { describe, it, expect } from 'vitest';
+import { MyExtensionApi } from '../src/api.js';
+import { mockFetch, makeHttpClient } from './helpers.js';
+
+describe('MyExtensionApi', () => {
+ it('list() calls GET /api/my_extension', async () => {
+ const items = [
+ { id: 1, name: 'Item 1', status: 'ACTIVE' as const, createdAt: '2025-01-01', updatedAt: '2025-01-01' },
+ { id: 2, name: 'Item 2', status: 'INACTIVE' as const, createdAt: '2025-01-02', updatedAt: '2025-01-02' },
+ ];
+ const fetch = mockFetch(200, items);
+ const api = new MyExtensionApi(makeHttpClient(fetch));
+
+ const result = await api.list();
+
+ expect(result).toEqual(items);
+ expect(fetch).toHaveBeenCalledOnce();
+ });
+
+ it('getById() calls GET /api/my_extension/{id}', async () => {
+ const item = { id: 1, name: 'Item 1', status: 'ACTIVE' as const, createdAt: '2025-01-01', updatedAt: '2025-01-01' };
+ const fetch = mockFetch(200, item);
+ const api = new MyExtensionApi(makeHttpClient(fetch));
+
+ const result = await api.getById(1);
+
+ expect(result).toEqual(item);
+ const [url] = fetch.mock.calls[0] as [string];
+ expect(url).toContain('/api/my_extension/1');
+ });
+
+ it('create() calls POST /api/my_extension', async () => {
+ const created = { id: 1, name: 'New Item', status: 'ACTIVE' as const, createdAt: '2025-01-01', updatedAt: '2025-01-01' };
+ const fetch = mockFetch(200, created);
+ const api = new MyExtensionApi(makeHttpClient(fetch));
+
+ const result = await api.create({ name: 'New Item' });
+
+ expect(result).toEqual(created);
+ const [url, init] = fetch.mock.calls[0] as [string, RequestInit];
+ expect(url).toContain('/api/my_extension');
+ expect(init.method).toBe('POST');
+ });
+
+ it('update() calls PUT /api/my_extension/{id}', async () => {
+ const updated = { id: 1, name: 'Updated Item', status: 'ACTIVE' as const, createdAt: '2025-01-01', updatedAt: '2025-01-02' };
+ const fetch = mockFetch(200, updated);
+ const api = new MyExtensionApi(makeHttpClient(fetch));
+
+ const result = await api.update(1, { name: 'Updated Item' });
+
+ expect(result).toEqual(updated);
+ const [url, init] = fetch.mock.calls[0] as [string, RequestInit];
+ expect(url).toContain('/api/my_extension/1');
+ expect(init.method).toBe('PUT');
+ });
+
+ it('delete() calls DELETE /api/my_extension/{id}', async () => {
+ const fetch = mockFetch(204);
+ const api = new MyExtensionApi(makeHttpClient(fetch));
+
+ await api.delete(1);
+
+ const [url, init] = fetch.mock.calls[0] as [string, RequestInit];
+ expect(url).toContain('/api/my_extension/1');
+ expect(init.method).toBe('DELETE');
+ });
+});
+```
+
+### README.md Template
+
+```markdown
+# @dynamia-tools/my_extension-sdk
+
+> Official TypeScript / JavaScript client SDK for the Dynamia My Extension REST API.
+
+\`@dynamia-tools/my_extension-sdk\` provides a small, focused client to interact with the My Extension of a Dynamia Platform backend. It exposes a single API class, \`MyExtensionApi\`, and TypeScript types for the extension's data models.
+
+The package delegates HTTP, authentication and error handling to the core \`@dynamia-tools/sdk\` \`HttpClient\`. The recommended usage is to construct \`MyExtensionApi\` from an existing \`DynamiaClient\` (\`client.http\`).
+
+---
+
+## Installation
+
+Install the package using your preferred package manager:
+
+\`\`\`bash
+# pnpm (recommended)
+pnpm add @dynamia-tools/my_extension-sdk
+
+# npm
+npm install @dynamia-tools/my_extension-sdk
+
+# yarn
+yarn add @dynamia-tools/my_extension-sdk
+\`\`\`
+
+Note: \`@dynamia-tools/my_extension-sdk\` declares a peer dependency on \`@dynamia-tools/sdk\`. The recommended pattern is to install and use the core SDK as well.
+
+---
+
+## Quick Start
+
+The easiest and most robust way to use the My Extension API is via the core \`DynamiaClient\`:
+
+\`\`\`ts
+import { DynamiaClient, DynamiaApiError } from '@dynamia-tools/sdk';
+import { MyExtensionApi } from '@dynamia-tools/my_extension-sdk';
+
+// Create the core client
+const client = new DynamiaClient({
+ baseUrl: 'https://app.example.com',
+ token: 'your-bearer-token',
+});
+
+// Construct the API using the client's HttpClient
+const api = new MyExtensionApi(client.http);
+
+// Use the API
+try {
+ const items = await api.list();
+ console.log(items);
+} catch (err) {
+ if (err instanceof DynamiaApiError) {
+ console.error(\`API error [\${err.status}] \${err.message}\`, err.body);
+ } else {
+ throw err;
+ }
+}
+\`\`\`
+
+---
+
+## API Reference
+
+### MyExtensionApi
+
+Main class for interacting with the My Extension API.
+
+#### Methods
+
+- **\`list()\`** — GET /api/my_extension
+ Returns all items.
+
+ \`\`\`ts
+ const items = await api.list();
+ \`\`\`
+
+- **\`getById(id)\`** — GET /api/my_extension/{id}
+ Retrieves a single item by ID.
+
+ \`\`\`ts
+ const item = await api.getById('item-123');
+ \`\`\`
+
+- **\`create(data)\`** — POST /api/my_extension
+ Creates a new item.
+
+ \`\`\`ts
+ const newItem = await api.create({ name: 'My Item' });
+ \`\`\`
+
+- **\`update(id, data)\`** — PUT /api/my_extension/{id}
+ Updates an existing item.
+
+ \`\`\`ts
+ const updated = await api.update('item-123', { name: 'Updated Name' });
+ \`\`\`
+
+- **\`delete(id)\`** — DELETE /api/my_extension/{id}
+ Deletes an item.
+
+ \`\`\`ts
+ await api.delete('item-123');
+ \`\`\`
+
+---
+
+## Types
+
+\`\`\`ts
+import type { MyExtensionDTO } from '@dynamia-tools/my_extension-sdk';
+
+const item: MyExtensionDTO = {
+ id: '123',
+ name: 'My Item',
+ description: 'Item description',
+ status: 'ACTIVE',
+ createdAt: '2025-01-01T00:00:00Z',
+ updatedAt: '2025-01-02T00:00:00Z',
+};
+\`\`\`
+
+---
+
+## Authentication & Errors
+
+Authentication is handled by \`DynamiaClient\` (bearer token, basic auth, or cookies). When constructing \`MyExtensionApi\`, ensure the client is configured with the appropriate credentials.
+
+All API errors are thrown as \`DynamiaApiError\` exceptions (from \`@dynamia-tools/sdk\`):
+
+\`\`\`ts
+try {
+ await api.getById('nonexistent');
+} catch (err) {
+ if (err instanceof DynamiaApiError) {
+ console.log(\`Status: \${err.status}\`);
+ console.log(\`Message: \${err.message}\`);
+ console.log(\`Body: \${err.body}\`);
+ }
+}
+\`\`\`
+
+---
+
+## Contributing
+
+See [CONTRIBUTING.md](../../CONTRIBUTING.md) in the framework root.
+
+---
+
+## License
+
+MIT — See [LICENSE](./LICENSE)
+```
+
+---
+
+## Usage in Applications
+
+### Option A: Direct API Usage
+
+```typescript
+import { DynamiaClient } from '@dynamia-tools/sdk';
+import { MyExtensionApi } from '@dynamia-tools/my_extension-sdk';
+
+const client = new DynamiaClient({ baseUrl: '...', token: '...' });
+const api = new MyExtensionApi(client.http);
+const items = await api.list();
+```
+
+### Option B: Vue Composable
+
+```typescript
+// composables/useMyExtension.ts
+import { ref } from 'vue';
+import { useDynamiaClient } from '@dynamia-tools/vue';
+import { MyExtensionApi } from '@dynamia-tools/my_extension-sdk';
+
+export function useMyExtensionItems() {
+ const client = useDynamiaClient();
+ const items = ref([]);
+ const loading = ref(false);
+ const error = ref(null);
+
+ async function load() {
+ if (!client) return;
+ loading.value = true;
+ try {
+ const api = new MyExtensionApi(client.http);
+ items.value = await api.list();
+ } catch (e) {
+ error.value = String(e);
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ return { items, loading, error, load };
+}
+```
+
+---
+
+## Checklist for New Extension SDKs
+
+- [ ] Directory structure created
+- [ ] package.json configured with peer dependency on `@dynamia-tools/sdk`
+- [ ] TypeScript config extends `tsconfig.base.json`
+- [ ] API class created in `src/api.ts` (accepts `HttpClient`)
+- [ ] Types defined in `src/types.ts`
+- [ ] Public exports in `src/index.ts`
+- [ ] Test helpers in `test/helpers.ts`
+- [ ] Tests created in `test/*.test.ts`
+- [ ] README.md with usage examples
+- [ ] LICENSE file copied from root
+- [ ] vite.config.ts and vitest.config.ts configured
+- [ ] Build script works: `npm run build`
+- [ ] Tests pass: `npm test`
+
+---
+
+## References
+
+- [API Client Standards](../../API_CLIENT_STANDARDS.md)
+- [API Client Audit Report](../../API_CLIENT_AUDIT_REPORT.md)
+- [Core SDK README](../../platform/packages/sdk/README.md)
+- [Reports SDK Example](../../extensions/reports/packages/reports-sdk)
+
diff --git a/docs/frontend/README.md b/docs/frontend/README.md
new file mode 100644
index 00000000..3c27666b
--- /dev/null
+++ b/docs/frontend/README.md
@@ -0,0 +1,326 @@
+# 📚 Frontend Documentation
+
+**Quick Navigation:** This folder contains comprehensive documentation for frontend development in Dynamia Tools.
+
+---
+
+## 📖 Documentation Structure
+
+This folder is organized to help frontend developers understand:
+- API client architecture and patterns
+- Frontend integration with Dynamia Platform
+- Standards for all frontend packages
+
+### Main Documents
+
+
+#### 1. **API_CLIENT_STANDARDS.md**
+- **Purpose:** Comprehensive standards and best practices for API clients
+- **For:** Frontend developers, API designers, code reviewers
+- **Time:** 30 minutes (reference document)
+- **Contains:**
+ - Core SDK architecture (`@dynamia-tools/sdk`)
+ - Extension SDK patterns (Reports, SaaS, Files)
+ - UI Core patterns (`@dynamia-tools/ui-core`)
+ - Standard patterns (✅ correct vs ❌ incorrect)
+ - File structure conventions
+ - Testing strategy
+
+#### 2. **COHERENCE_RECOMMENDATIONS.md**
+- **Purpose:** Current state analysis and future recommendations
+- **For:** Architects, project leads, maintainers
+- **Time:** 25 minutes
+- **Contains:**
+ - Current strengths in architecture
+ - Identified gaps
+ - Recommendations for improvement
+ - Implementation roadmap (3 phases)
+ - Pattern consistency checklist
+
+#### 3. **EXTENSION_SDK_TEMPLATE.md**
+- **Purpose:** Complete template for creating new extension SDKs
+- **For:** Backend developers adding new extensions
+- **Time:** 40 minutes + implementation time
+- **Contains:**
+ - Directory structure template
+ - Step-by-step creation guide
+ - TypeScript & Vite configuration
+ - Complete file templates
+ - Testing strategy
+ - Deployment checklist
+
+---
+
+## 🎯 How to Use This Documentation
+
+### If You're a...
+
+#### 👨💼 **Project Manager / Lead**
+1. Reference: `COHERENCE_RECOMMENDATIONS.md` for roadmap
+
+#### 👨💻 **Frontend Developer (Vue, React, etc.)**
+1. Read: `API_CLIENT_STANDARDS.md` → "UI Packages" section (20 min)
+2. Find related: `platform/packages/vue/GUIDE.md` in main framework docs
+3. Reference: Standard patterns for code review
+
+#### 🔧 **SDK/Backend Developer**
+1. Read: `EXTENSION_SDK_TEMPLATE.md` (40 min)
+2. Follow step-by-step guide
+3. Use provided templates
+4. Check against compliance checklist
+
+#### 👀 **Code Reviewer**
+1. Reference: `API_CLIENT_STANDARDS.md` → "Standard Patterns" section
+2. Check: `COHERENCE_RECOMMENDATIONS.md` → "Pattern Consistency Checklist"
+3. Verify: Patterns match documented standards
+
+#### 🏗️ **Architect**
+1. Read: All documents (1.5 hours total)
+2. Understand: Complete architecture from multiple angles
+3. Plan: Next steps using recommendations roadmap
+
+---
+
+## 🏗️ Architecture Overview
+
+All frontend communication flows through a **centralized HttpClient**:
+
+```
+Frontend Components (Vue/React)
+ ↓
+Composables / Hooks (useDynamiaClient, useNavigation, etc.)
+ ↓
+API Classes (MetadataApi, CrudApi, MyExtensionApi, etc.)
+ ↓
+HttpClient + DynamiaClient (centralized)
+ ├─ Authentication (Bearer tokens, Basic Auth, Cookies)
+ ├─ URL Building (baseUrl + normalization + query params)
+ ├─ Error Handling (DynamiaApiError)
+ └─ Fetch Implementation (pluggable for testing)
+```
+
+**Result:** Single point of control for all HTTP communication, authentication, and error handling.
+
+---
+
+## ✅ Key Standards Enforced
+
+### ✅ Correct Pattern
+```typescript
+// All API classes accept HttpClient in constructor
+export class MyApi {
+ private readonly http: HttpClient;
+
+ constructor(http: HttpClient) {
+ this.http = http;
+ }
+
+ async getData() {
+ return this.http.get('/api/data');
+ }
+}
+
+// Usage in frontend
+const client = useDynamiaClient(); // From Vue plugin
+const api = new MyApi(client.http);
+const data = await api.getData();
+```
+
+### ❌ Incorrect Patterns (Don't Do This)
+```typescript
+// ❌ Direct fetch() calls
+async function getData() {
+ const res = await fetch('/api/data');
+ return res.json();
+}
+
+// ❌ Using axios directly
+import axios from 'axios';
+const data = await axios.get('/api/data');
+
+// ❌ Hardcoding headers
+fetch('/api/data', {
+ headers: { 'Authorization': 'Bearer ...' }
+});
+```
+
+---
+
+## 📊 Compliance Status
+
+| Aspect | Status |
+|--------|--------|
+| All packages use centralized HttpClient | ✅ |
+| Direct fetch() calls outside HttpClient | ✅ 0 found |
+| axios usage in framework | ✅ 0 found |
+| Consistent authentication | ✅ |
+| Uniform error handling | ✅ |
+| All responses typed (TypeScript) | ✅ |
+| Testable with mocks | ✅ |
+| **Overall Compliance** | **✅ 100%** |
+
+---
+
+## 📦 Related Documentation
+
+### In This Folder
+- `API_CLIENT_STANDARDS.md` — Detailed standards
+- `COHERENCE_RECOMMENDATIONS.md` — Future roadmap
+- `EXTENSION_SDK_TEMPLATE.md` — SDK template
+
+
+### Related Packages
+- `@dynamia-tools/sdk` — Core client (`platform/packages/sdk/README.md`)
+- `@dynamia-tools/vue` — Vue adapter (`platform/packages/vue/README.md`)
+- `@dynamia-tools/ui-core` — UI framework (`platform/packages/ui-core/README.md`)
+- Extension SDKs:
+ - Reports: `extensions/reports/packages/reports-sdk/README.md`
+ - SaaS: `extensions/saas/packages/saas-sdk/README.md`
+ - Files: `extensions/entity-files/packages/files-sdk/README.md`
+
+---
+
+## 🔗 Quick Links
+
+### Learn
+- [API Client Standards](./API_CLIENT_STANDARDS.md) — Patterns & conventions
+- [Recommendations](./COHERENCE_RECOMMENDATIONS.md) — Future direction
+
+### Build
+- [Extension SDK Template](./EXTENSION_SDK_TEMPLATE.md) — Create new SDKs
+- [Vue Integration Guide](../../platform/packages/vue/GUIDE.md) — Build with Vue
+
+### Review
+- Check patterns: `API_CLIENT_STANDARDS.md`
+- Verify compliance: `COHERENCE_RECOMMENDATIONS.md` → Checklist
+
+---
+
+## 🚀 Getting Started
+
+### For New Frontend Developers
+
+**Step 1:** Read Overview (5 min)
+```
+Read this file
+Focus on: Architecture, Key Findings
+```
+
+**Step 2:** Learn Patterns (20 min)
+```
+Read: API_CLIENT_STANDARDS.md
+Focus on: Standard Patterns section
+Study: ✅ Correct patterns with examples
+```
+
+**Step 3:** Understand Framework (20 min)
+```
+Read: platform/packages/vue/GUIDE.md
+Focus on: Setup, Composables Reference
+```
+
+**Step 4:** Code Along (30 min)
+```
+Study: Real-world examples in Vue guide
+Run: examples/demo-vue-books
+```
+
+**Total Time:** ~1.5 hours to get started
+
+### For Creating New Features
+
+1. Check: `API_CLIENT_STANDARDS.md` → Relevant section
+2. Follow: The ✅ correct pattern
+3. Code: Your implementation
+4. Review: Against patterns & checklist
+5. Test: Using mock helpers
+
+### For Code Review
+
+1. Reference: `API_CLIENT_STANDARDS.md` → Standard Patterns
+2. Verify: Code matches ✅ correct patterns
+3. Check: `COHERENCE_RECOMMENDATIONS.md` → Checklist
+4. Approve/Request changes based on standards
+
+---
+
+## 📊 Documentation Quality Metrics
+
+| Metric | Value |
+|--------|-------|
+| Total documents in this folder | 4 |
+| Total lines of documentation | ~1,300 |
+| Code examples | 50+ |
+| Real patterns documented | 5+ |
+| Packages covered | 6 |
+| Compliance score | 100% ✅ |
+
+---
+
+## 🎓 Key Concepts
+
+### DynamiaClient
+Central client for all Dynamia Platform REST APIs. Handles:
+- Authentication configuration
+- HTTP client instantiation
+- Sub-API initialization (Metadata, CRUD, Actions, etc.)
+
+### HttpClient
+Low-level HTTP wrapper that provides:
+- Fetch implementation (pluggable)
+- Auth header management
+- URL building with query params
+- Error handling (throws `DynamiaApiError`)
+
+### Extension SDKs
+Focused API clients for specific extensions (Reports, SaaS, Files).
+All follow the same pattern:
+- Accept `HttpClient` in constructor
+- Use only `this.http.get/post/put/delete()`
+- Provide typed promises
+- Include tests with mocks
+
+### UI Packages
+Framework-agnostic view classes that:
+- Don't call HTTP directly
+- Accept data via injected callbacks
+- Are testable with mock data
+- Work with Vue, React, Svelte, etc.
+
+---
+
+## ❓ FAQ
+
+**Q: Where do I find Vue-specific docs?**
+A: `platform/packages/vue/GUIDE.md`
+
+**Q: How do I create a new extension SDK?**
+A: Follow `EXTENSION_SDK_TEMPLATE.md`
+
+**Q: What patterns should my code follow?**
+A: See `API_CLIENT_STANDARDS.md` → Standard Patterns
+
+**Q: How is authentication handled?**
+A: See `API_CLIENT_STANDARDS.md` → Architecture → DynamiaClient
+
+**Q: How do I test my frontend code?**
+A: See `API_CLIENT_STANDARDS.md` → Testing Strategy
+
+
+
+---
+
+## 📞 Support
+
+### Finding Information
+1. Check this README (you're reading it!)
+2. Search relevant document using Ctrl+F
+3. Follow role-specific guide above
+4. Reference `COHERENCE_RECOMMENDATIONS.md` for patterns
+
+### Contributing
+- Follow patterns in `API_CLIENT_STANDARDS.md`
+- Check against `COHERENCE_RECOMMENDATIONS.md` → Checklist
+- Use `EXTENSION_SDK_TEMPLATE.md` for new SDKs
+
+
diff --git a/examples/demo-vue-books/.env.example b/examples/demo-vue-books/.env.example
new file mode 100644
index 00000000..c657180a
--- /dev/null
+++ b/examples/demo-vue-books/.env.example
@@ -0,0 +1,9 @@
+VITE_DYNAMIA_API_URL=
+
+# Optional bearer token auth
+# VITE_DYNAMIA_TOKEN=
+
+# Optional basic auth
+# VITE_DYNAMIA_USERNAME=
+# VITE_DYNAMIA_PASSWORD=
+
diff --git a/examples/demo-vue-books/.gitignore b/examples/demo-vue-books/.gitignore
new file mode 100644
index 00000000..da333fcd
--- /dev/null
+++ b/examples/demo-vue-books/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.env
+
diff --git a/examples/demo-vue-books/README.md b/examples/demo-vue-books/README.md
new file mode 100644
index 00000000..54e41dd8
--- /dev/null
+++ b/examples/demo-vue-books/README.md
@@ -0,0 +1,56 @@
+# Dynamia Tools Vue Backoffice Demo
+
+Backoffice demo built with **Vue 3** + **@dynamia-tools/vue**.
+
+It connects to a Dynamia backend (default `http://localhost:8484`) and renders:
+
+- a left navigation menu,
+- a breadcrumb on top,
+- a center panel that auto-renders CRUD pages.
+
+When a selected node is not `CrudPage`, the demo shows a fallback message.
+
+## Prerequisites
+
+- Node.js >= 20
+- A running Dynamia backend with navigation metadata and CRUD pages
+
+## Configuration
+
+Copy `.env.example` to `.env` and adjust values if needed.
+
+```bash
+cp .env.example .env
+```
+
+Available variables:
+
+- `VITE_DYNAMIA_API_URL` (default: `http://localhost:8484`)
+- `VITE_DYNAMIA_TOKEN` (optional bearer token)
+- `VITE_DYNAMIA_USERNAME` / `VITE_DYNAMIA_PASSWORD` (optional basic auth)
+
+## Quick Start
+
+```bash
+npm install
+npm run dev
+```
+
+Then open the URL printed by Vite (usually `http://localhost:5173`).
+
+## Scripts
+
+```bash
+npm run dev
+npm run build
+npm run preview
+npm run typecheck
+```
+
+## What You Should See
+
+- **Left:** module/page navigation (``)
+- **Top:** active location breadcrumb (``)
+- **Center:**
+ - `CrudPage` nodes render with ``
+ - other node types render an informational message
diff --git a/examples/demo-vue-books/index.html b/examples/demo-vue-books/index.html
new file mode 100644
index 00000000..b49f86a2
--- /dev/null
+++ b/examples/demo-vue-books/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Dynamia Vue Backoffice Demo
+
+
+
+
+
+
+
diff --git a/examples/demo-vue-books/package-lock.json b/examples/demo-vue-books/package-lock.json
new file mode 100644
index 00000000..cddf14b8
--- /dev/null
+++ b/examples/demo-vue-books/package-lock.json
@@ -0,0 +1,1571 @@
+{
+ "name": "demo-vue-books",
+ "version": "26.3.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "demo-vue-books",
+ "version": "26.3.1",
+ "dependencies": {
+ "@dynamia-tools/sdk": "file:../../platform/packages/sdk",
+ "@dynamia-tools/ui-core": "file:../../platform/packages/ui-core",
+ "@dynamia-tools/vue": "file:../../platform/packages/vue",
+ "vue": "^3.5.13"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.3",
+ "typescript": "^5.7.3",
+ "vite": "^6.2.0",
+ "vue-tsc": "^2.2.0"
+ }
+ },
+ "../../platform/packages/sdk": {
+ "name": "@dynamia-tools/sdk",
+ "version": "26.3.1",
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "@vitest/coverage-v8": "^3.0.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.2.0",
+ "vite-plugin-dts": "^4.5.0",
+ "vitest": "^3.0.0"
+ }
+ },
+ "../../platform/packages/ui-core": {
+ "name": "@dynamia-tools/ui-core",
+ "version": "26.3.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@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"
+ }
+ },
+ "../../platform/packages/vue": {
+ "name": "@dynamia-tools/vue",
+ "version": "26.3.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@dynamia-tools/sdk": ">=26.0.0",
+ "@dynamia-tools/ui-core": ">=26.0.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"
+ },
+ "peerDependencies": {
+ "vue": "^3.4.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@dynamia-tools/sdk": {
+ "resolved": "../../platform/packages/sdk",
+ "link": true
+ },
+ "node_modules/@dynamia-tools/ui-core": {
+ "resolved": "../../platform/packages/ui-core",
+ "link": true
+ },
+ "node_modules/@dynamia-tools/vue": {
+ "resolved": "../../platform/packages/vue",
+ "link": true
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
+ "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
+ "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.30",
+ "@vue/shared": "3.5.30"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
+ "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
+ "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.30",
+ "@vue/shared": "3.5.30"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
+ "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.30"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
+ "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.30",
+ "@vue/shared": "3.5.30"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
+ "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.30",
+ "@vue/runtime-core": "3.5.30",
+ "@vue/shared": "3.5.30",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
+ "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.30",
+ "@vue/shared": "3.5.30"
+ },
+ "peerDependencies": {
+ "vue": "3.5.30"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
+ "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
+ "license": "MIT"
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.30",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
+ "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
+ "license": "MIT",
+ "peer": true,
+ "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/shared": "3.5.30"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ }
+ }
+}
diff --git a/examples/demo-vue-books/package.json b/examples/demo-vue-books/package.json
new file mode 100644
index 00000000..12d1112d
--- /dev/null
+++ b/examples/demo-vue-books/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "demo-vue-books",
+ "version": "26.3.1",
+ "description": "Backoffice demo built with Vue 3 + @dynamia-tools/vue",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "@dynamia-tools/sdk": "file:../../platform/packages/sdk",
+ "@dynamia-tools/ui-core": "file:../../platform/packages/ui-core",
+ "@dynamia-tools/vue": "file:../../platform/packages/vue",
+ "vue": "^3.5.13"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.3",
+ "typescript": "^5.7.3",
+ "vite": "^6.2.0",
+ "vue-tsc": "^2.2.0"
+ }
+}
+
diff --git a/examples/demo-vue-books/src/App.vue b/examples/demo-vue-books/src/App.vue
new file mode 100644
index 00000000..f9125c60
--- /dev/null
+++ b/examples/demo-vue-books/src/App.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+ Loading navigation...
+
+
+
Could not load navigation from backend.
+
{{ error }}
+
Retry
+
+
+
+
+
+
+ Selected node type {{ activeNode.type }} is not mapped in this demo.
+
+
Path: {{ activeNode.internalPath || 'N/A' }}
+
+
+
+ Select a page from the navigation menu.
+
+
+
+
+
+
+
+
diff --git a/examples/demo-vue-books/src/env.d.ts b/examples/demo-vue-books/src/env.d.ts
new file mode 100644
index 00000000..ecc646c9
--- /dev/null
+++ b/examples/demo-vue-books/src/env.d.ts
@@ -0,0 +1,14 @@
+///
+
+
+interface ImportMetaEnv {
+ readonly VITE_DYNAMIA_API_URL?: string;
+ readonly VITE_DYNAMIA_TOKEN?: string;
+ readonly VITE_DYNAMIA_USERNAME?: string;
+ readonly VITE_DYNAMIA_PASSWORD?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
+
diff --git a/examples/demo-vue-books/src/lib/client.ts b/examples/demo-vue-books/src/lib/client.ts
new file mode 100644
index 00000000..7c47cec4
--- /dev/null
+++ b/examples/demo-vue-books/src/lib/client.ts
@@ -0,0 +1,14 @@
+import { DynamiaClient } from '@dynamia-tools/sdk';
+
+const baseUrl = import.meta.env.VITE_DYNAMIA_API_URL ?? '';
+const token = import.meta.env.VITE_DYNAMIA_TOKEN;
+const username = import.meta.env.VITE_DYNAMIA_USERNAME;
+const password = import.meta.env.VITE_DYNAMIA_PASSWORD;
+
+export const client = new DynamiaClient({
+ baseUrl,
+ token: token || undefined,
+ username: username || undefined,
+ password: password || undefined,
+});
+
diff --git a/examples/demo-vue-books/src/main.ts b/examples/demo-vue-books/src/main.ts
new file mode 100644
index 00000000..3294bf23
--- /dev/null
+++ b/examples/demo-vue-books/src/main.ts
@@ -0,0 +1,9 @@
+import { createApp } from 'vue';
+import { DynamiaVue } from '@dynamia-tools/vue';
+import App from './App.vue';
+import './style.css';
+
+const app = createApp(App);
+app.use(DynamiaVue);
+app.mount('#app');
+
diff --git a/examples/demo-vue-books/src/style.css b/examples/demo-vue-books/src/style.css
new file mode 100644
index 00000000..c154efc6
--- /dev/null
+++ b/examples/demo-vue-books/src/style.css
@@ -0,0 +1,401 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+ --bg: #0b1220;
+ --surface: #111827;
+ --surface-2: #0f172a;
+ --border: #273449;
+ --text: #e2e8f0;
+ --muted: #94a3b8;
+ --primary: #38bdf8;
+ --primary-soft: rgba(56, 189, 248, 0.18);
+ --danger: #ef4444;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ height: 100%;
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+}
+
+.layout {
+ display: grid;
+ grid-template-columns: 300px 1fr;
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+}
+
+.sidebar {
+ overflow: auto;
+ padding: 1rem;
+ border-right: 1px solid var(--border);
+ background: var(--surface-2);
+}
+
+.brand {
+ margin: 0 0 1rem;
+ font-size: 1rem;
+ letter-spacing: 0.02em;
+ color: #f8fafc;
+}
+
+.workspace {
+ display: grid;
+ grid-template-rows: auto 1fr;
+ min-width: 0;
+}
+
+.topbar {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.content {
+ overflow: auto;
+ padding: 1rem;
+}
+
+.panel-state {
+ display: grid;
+ gap: 0.5rem;
+ align-content: start;
+ padding: 1rem;
+ border: 1px dashed #41526e;
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.panel-state-error {
+ border-color: var(--danger);
+}
+
+.small {
+ font-size: 0.875rem;
+ color: var(--muted);
+}
+
+code {
+ padding: 0.125rem 0.375rem;
+ border-radius: 4px;
+ background: #1e293b;
+ color: #cbd5e1;
+}
+
+button {
+ width: fit-content;
+ border: 1px solid #4b5f80;
+ border-radius: 6px;
+ padding: 0.5rem 0.75rem;
+ background: #1e293b;
+ color: var(--text);
+ cursor: pointer;
+}
+
+button:hover {
+ background: #273449;
+}
+
+/* Improve dark look for built-in navigation components */
+.dynamia-nav-menu {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.dynamia-nav-module-header,
+.dynamia-nav-group-header {
+ color: #f1f5f9;
+}
+
+.dynamia-nav-group-header {
+ margin: 0.5rem 0 0.25rem;
+ font-size: 0.85rem;
+ color: var(--muted);
+}
+
+.dynamia-nav-pages {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.dynamia-nav-page {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0.125rem 0;
+ padding: 0.45rem 0.6rem;
+ border-radius: 6px;
+ color: #cbd5e1;
+ cursor: pointer;
+}
+
+.dynamia-nav-page:hover {
+ background: #1a2435;
+}
+
+.dynamia-nav-page-active {
+ background: var(--primary-soft);
+ color: #e0f2fe;
+}
+
+.dynamia-breadcrumb {
+ color: #cbd5e1;
+}
+
+.dynamia-breadcrumb-list {
+ display: flex;
+ gap: 0.4rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.dynamia-breadcrumb-separator {
+ color: var(--muted);
+}
+
+.dynamia-breadcrumb-page {
+ color: #e0f2fe;
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ CRUD PAGE / CRUD wrapper
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+.dynamia-crud-page,
+.dynamia-crud {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.dynamia-crud-page-loading,
+.dynamia-crud-loading,
+.dynamia-crud-page-error {
+ padding: 0.75rem 1rem;
+ border-radius: 6px;
+ background: var(--surface);
+ border: 1px dashed var(--border);
+ color: var(--muted);
+ font-size: 0.875rem;
+}
+
+.dynamia-crud-page-error {
+ border-color: var(--danger);
+ color: var(--danger);
+}
+
+.dynamia-crud-error {
+ padding: 0.5rem 0.75rem;
+ border-radius: 4px;
+ background: rgba(239, 68, 68, 0.12);
+ border: 1px solid var(--danger);
+ color: var(--danger);
+ font-size: 0.875rem;
+}
+
+.dynamia-crud-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ TABLE
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+.dynamia-table {
+ overflow-x: auto;
+}
+
+.dynamia-table-loading {
+ padding: 1rem;
+ color: var(--muted);
+ font-size: 0.875rem;
+}
+
+.dynamia-table-grid {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.dynamia-table-header {
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ background: var(--surface);
+ border-bottom: 2px solid var(--border);
+ color: var(--muted);
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.dynamia-table-header:hover {
+ color: var(--text);
+}
+
+.dynamia-table-actions-header {
+ padding: 0.5rem 0.75rem;
+ background: var(--surface);
+ border-bottom: 2px solid var(--border);
+ color: var(--muted);
+ font-size: 0.8rem;
+ text-align: right;
+}
+
+.dynamia-table-row {
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.dynamia-table-row:hover {
+ background: #1a2435;
+}
+
+.dynamia-table-row-selected {
+ background: var(--primary-soft) !important;
+}
+
+.dynamia-table-cell {
+ padding: 0.5rem 0.75rem;
+ color: var(--text);
+ vertical-align: middle;
+}
+
+.dynamia-table-actions-cell {
+ padding: 0.35rem 0.75rem;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.dynamia-table-empty {
+ padding: 1.5rem;
+ text-align: center;
+ color: var(--muted);
+ font-style: italic;
+}
+
+.dynamia-table-pagination {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0;
+ font-size: 0.85rem;
+ color: var(--muted);
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ FORM
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+.dynamia-form {
+ width: 100%;
+}
+
+.dynamia-form-loading {
+ padding: 1rem;
+ color: var(--muted);
+ font-size: 0.875rem;
+}
+
+/* ── Field group ─────────────────────────────────────────────────────────── */
+.dynamia-form-group {
+ margin-bottom: 1.5rem;
+}
+
+.dynamia-form-group-header {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--primary);
+ padding: 0.4rem 0 0.45rem;
+ margin-bottom: 0.75rem;
+ border-bottom: 1px solid var(--border);
+}
+
+/* ── Grid (--columns is set inline by Form.vue) ──────────────────────────── */
+.dynamia-form-row {
+ display: grid;
+ grid-template-columns: repeat(var(--columns, 2), 1fr);
+ gap: 0.5rem 1rem;
+ margin-bottom: 0.35rem;
+}
+
+.dynamia-form-cell {
+ min-width: 0; /* prevent grid blowout */
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+/* ── Label ───────────────────────────────────────────────────────────────── */
+.dynamia-form-label {
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--muted);
+}
+
+.dynamia-required {
+ color: var(--danger);
+ margin-left: 0.15rem;
+}
+
+/* ── Inputs ──────────────────────────────────────────────────────────────── */
+.dynamia-form input[type='text'],
+.dynamia-form input[type='number'],
+.dynamia-form input[type='email'],
+.dynamia-form input[type='password'],
+.dynamia-form input[type='date'],
+.dynamia-form input[type='datetime-local'],
+.dynamia-form input[type='time'],
+.dynamia-form select,
+.dynamia-form textarea {
+ width: 100%;
+ padding: 0.4rem 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--surface-2);
+ color: var(--text);
+ font-size: 0.875rem;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.dynamia-form input:focus,
+.dynamia-form select:focus,
+.dynamia-form textarea:focus {
+ border-color: var(--primary);
+}
+
+/* ── Validation error ────────────────────────────────────────────────────── */
+.dynamia-field-error {
+ font-size: 0.75rem;
+ color: var(--danger);
+}
+
+/* ── Actions bar ─────────────────────────────────────────────────────────── */
+.dynamia-form-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ justify-content: flex-end;
+}
+
+
diff --git a/examples/demo-vue-books/tsconfig.json b/examples/demo-vue-books/tsconfig.json
new file mode 100644
index 00000000..baffc0fa
--- /dev/null
+++ b/examples/demo-vue-books/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "vite.config.ts"]
+}
+
diff --git a/examples/demo-vue-books/vite.config.ts b/examples/demo-vue-books/vite.config.ts
new file mode 100644
index 00000000..db238447
--- /dev/null
+++ b/examples/demo-vue-books/vite.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ proxy: {
+ // Todas las peticiones que empiecen con /api se redirigirán
+ '/api': {
+ target: 'http://localhost:8484', // La URL de tu Spring Boot
+ changeOrigin: true,
+ secure: false,
+ // Opcional: si tu backend NO tiene el prefijo /api,
+ // pero tu frontend sí lo usa para organizarse:
+ // rewrite: (path) => path.replace(/^\/api/, '')
+ }
+ }
+ }
+});
+
diff --git a/examples/demo-zk-books/pom.xml b/examples/demo-zk-books/pom.xml
index 4560860f..4a633acb 100644
--- a/examples/demo-zk-books/pom.xml
+++ b/examples/demo-zk-books/pom.xml
@@ -47,7 +47,7 @@
${maven.build.timestamp}
yyyyMMdd
- 26.3.1
+ 26.3.2
diff --git a/examples/demo-zk-books/src/main/java/mybookstore/MyBookStoreApplication.java b/examples/demo-zk-books/src/main/java/mybookstore/MyBookStoreApplication.java
index c93ec110..fe18db06 100644
--- a/examples/demo-zk-books/src/main/java/mybookstore/MyBookStoreApplication.java
+++ b/examples/demo-zk-books/src/main/java/mybookstore/MyBookStoreApplication.java
@@ -17,6 +17,7 @@
package mybookstore;
+import mybookstore.domain.Book;
import mybookstore.domain.Category;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -118,4 +119,18 @@ public UserInfo userInfo() {
userInfo.setImage("/static/user-photo.jpg");
return userInfo;
}
+
+ /**
+ * Entity reference repository to integrate entities in external modules without direct relation
+ * @return repository
+ */
+ @Bean
+ public EntityReferenceRepository categoryReferenceRepository() {
+ return new DefaultEntityReferenceRepository<>(Category.class, "name");
+ }
+
+ @Bean
+ public EntityReferenceRepository bookReferenceRepository() {
+ return new DefaultEntityReferenceRepository<>(Book.class, "title");
+ }
}
diff --git a/examples/demo-zk-books/src/main/java/mybookstore/WebConfig.java b/examples/demo-zk-books/src/main/java/mybookstore/WebConfig.java
new file mode 100644
index 00000000..02e95748
--- /dev/null
+++ b/examples/demo-zk-books/src/main/java/mybookstore/WebConfig.java
@@ -0,0 +1,21 @@
+package mybookstore;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/api/**")
+ .allowedOrigins("http://localhost:5173")
+ .allowedOriginPatterns("*")
+ .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
+ .allowedHeaders("*")
+ .exposedHeaders("Authorization")
+ .allowCredentials(true)
+ .maxAge(3600);
+ }
+}
\ No newline at end of file
diff --git a/examples/demo-zk-books/src/main/java/mybookstore/domain/BookLog.java b/examples/demo-zk-books/src/main/java/mybookstore/domain/BookLog.java
new file mode 100644
index 00000000..8d3d157a
--- /dev/null
+++ b/examples/demo-zk-books/src/main/java/mybookstore/domain/BookLog.java
@@ -0,0 +1,69 @@
+package mybookstore.domain;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
+import tools.dynamia.domain.Descriptor;
+import tools.dynamia.domain.EntityReference;
+import tools.dynamia.domain.Reference;
+import tools.dynamia.domain.jpa.SimpleEntity;
+import tools.dynamia.domain.util.DomainUtils;
+
+@Entity
+@Table(name = "book_logs")
+@Descriptor(fields = {"id", "creationTimestamp", "bookId", "categoryId", "message"})
+public class BookLog extends SimpleEntity {
+
+
+ @NotNull
+ @Reference("Book")
+ @Descriptor(label = "Book")
+ private Long bookId;
+
+ @NotNull
+ @Reference("Category")
+ @Descriptor(label = "Category")
+ private Long categoryId;
+ private String mesesage;
+
+ public BookLog() {
+ }
+
+ public BookLog(Long bookId, Long categoryId, String mesesage) {
+ this.bookId = bookId;
+ this.categoryId = categoryId;
+ this.mesesage = mesesage;
+ }
+
+ public EntityReference getBookRef() {
+ return DomainUtils.getEntityReference("Book", getBookId());
+ }
+
+ public EntityReference getCategoryRef() {
+ return DomainUtils.getEntityReference("Category", getCategoryId());
+ }
+
+ public Long getBookId() {
+ return bookId;
+ }
+
+ public void setBookId(Long bookId) {
+ this.bookId = bookId;
+ }
+
+ public Long getCategoryId() {
+ return categoryId;
+ }
+
+ public void setCategoryId(Long categoryId) {
+ this.categoryId = categoryId;
+ }
+
+ public String getMesesage() {
+ return mesesage;
+ }
+
+ public void setMesesage(String mesesage) {
+ this.mesesage = mesesage;
+ }
+}
diff --git a/examples/demo-zk-books/src/main/java/mybookstore/providers/MyBookStoreModuleProvider.java b/examples/demo-zk-books/src/main/java/mybookstore/providers/MyBookStoreModuleProvider.java
index 1cc41426..94662ba7 100644
--- a/examples/demo-zk-books/src/main/java/mybookstore/providers/MyBookStoreModuleProvider.java
+++ b/examples/demo-zk-books/src/main/java/mybookstore/providers/MyBookStoreModuleProvider.java
@@ -17,10 +17,7 @@
package mybookstore.providers;
-import mybookstore.domain.Book;
-import mybookstore.domain.Category;
-import mybookstore.domain.Customer;
-import mybookstore.domain.Invoice;
+import mybookstore.domain.*;
import tools.dynamia.crud.CrudPage;
import tools.dynamia.integration.sterotypes.Provider;
import tools.dynamia.navigation.*;
@@ -41,7 +38,8 @@ public Module getModule() { //<2>
new CrudPage("books", "Books", Book.class),
new CrudPage("categories", "Categories", Category.class).icon("tree"),
new CrudPage("customers", "Customers", Customer.class).icon("people"),
- new CrudPage("invoices", "Invoices", Invoice.class)
+ new CrudPage("invoices", "Invoices", Invoice.class),
+ new CrudPage("logs", "Logs", BookLog.class).icon("clipboard-list")
)
.addPageGroup(new PageGroup("examples", "More Examples")
.addPage(
diff --git a/examples/demo-zk-books/test/MetadataApi.http b/examples/demo-zk-books/test/MetadataApi.http
new file mode 100644
index 00000000..3831d734
--- /dev/null
+++ b/examples/demo-zk-books/test/MetadataApi.http
@@ -0,0 +1,26 @@
+@url = http://localhost:8484/api/app/metadata
+### Load Metadata
+GET {{url}}
+
+### Load Entities
+GET {{url}}/entities
+
+### Load Entity
+GET {{url}}/entities/mybookstore.domain.Book
+
+
+### Load Entity Views
+GET {{url}}/entities/mybookstore.domain.Book/views
+
+### Load Entity Views
+GET {{url}}/entities/mybookstore.domain.Customer/views
+
+### Load Entity Reference
+GET {{url}}/entities/ref/Book/1
+
+
+### Search Entity Reference
+GET {{url}}/entities/ref/Book/search?q=My
+
+### Load Entity Views with entity references
+GET {{url}}/entities/mybookstore.domain.BookLog/views
\ No newline at end of file
diff --git a/extensions/dashboard/sources/pom.xml b/extensions/dashboard/sources/pom.xml
index 8b50d5f0..525f2c89 100644
--- a/extensions/dashboard/sources/pom.xml
+++ b/extensions/dashboard/sources/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.parent
- 26.3.1
+ 26.4.0
../../pom.xml
@@ -38,12 +38,12 @@
tools.dynamia
tools.dynamia.zk
- 26.3.1
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.saas.api
- 26.3.1
+ 26.4.0
diff --git a/extensions/dashboard/sources/src/main/java/tools/dynamia/modules/dashboard/DashboardAction.java b/extensions/dashboard/sources/src/main/java/tools/dynamia/modules/dashboard/DashboardAction.java
index 5ef315c4..70ea9236 100755
--- a/extensions/dashboard/sources/src/main/java/tools/dynamia/modules/dashboard/DashboardAction.java
+++ b/extensions/dashboard/sources/src/main/java/tools/dynamia/modules/dashboard/DashboardAction.java
@@ -17,11 +17,11 @@
package tools.dynamia.modules.dashboard;
-import tools.dynamia.actions.AbstractAction;
+import tools.dynamia.actions.AbstractLocalAction;
/**
* Dashboard actions
*/
-public abstract class DashboardAction extends AbstractAction {
+public abstract class DashboardAction extends AbstractLocalAction {
}
diff --git a/extensions/email-sms/sources/core/pom.xml b/extensions/email-sms/sources/core/pom.xml
index 94765841..f4c600af 100644
--- a/extensions/email-sms/sources/core/pom.xml
+++ b/extensions/email-sms/sources/core/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules.email.parent
tools.dynamia.modules
- 26.3.1
+ 26.4.0
tools.dynamia.modules.email
@@ -50,12 +50,12 @@
tools.dynamia
tools.dynamia.domain.jpa
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.templates
- 26.3.1
+ 26.4.0
org.springframework
diff --git a/extensions/email-sms/sources/pom.xml b/extensions/email-sms/sources/pom.xml
index fd9d27a9..2a1c5fa0 100644
--- a/extensions/email-sms/sources/pom.xml
+++ b/extensions/email-sms/sources/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.parent
- 26.3.1
+ 26.4.0
../../pom.xml
@@ -85,7 +85,7 @@
tools.dynamia.modules
tools.dynamia.modules.saas.jpa
- 26.3.1
+ 26.4.0
diff --git a/extensions/email-sms/sources/ui/pom.xml b/extensions/email-sms/sources/ui/pom.xml
index bc52bc3e..f275a54b 100644
--- a/extensions/email-sms/sources/ui/pom.xml
+++ b/extensions/email-sms/sources/ui/pom.xml
@@ -22,7 +22,7 @@
tools.dynamia.modules.email.parent
tools.dynamia.modules
- 26.3.1
+ 26.4.0
DynamiaModules - Email UI
@@ -34,12 +34,12 @@
tools.dynamia
tools.dynamia.zk
- 26.3.1
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.email
- 26.3.1
+ 26.4.0
tools.dynamia.zk.addons
diff --git a/extensions/email-sms/sources/ui/src/main/java/tools/dynamia/modules/email/ui/actions/TestEmailAccountAction.java b/extensions/email-sms/sources/ui/src/main/java/tools/dynamia/modules/email/ui/actions/TestEmailAccountAction.java
index d994bd77..af9deaff 100644
--- a/extensions/email-sms/sources/ui/src/main/java/tools/dynamia/modules/email/ui/actions/TestEmailAccountAction.java
+++ b/extensions/email-sms/sources/ui/src/main/java/tools/dynamia/modules/email/ui/actions/TestEmailAccountAction.java
@@ -20,7 +20,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.zkoss.zul.Messagebox;
-import tools.dynamia.actions.AbstractAction;
+import tools.dynamia.actions.AbstractLocalAction;
import tools.dynamia.actions.ActionEvent;
import tools.dynamia.actions.ActionRenderer;
import tools.dynamia.actions.InstallAction;
@@ -109,7 +109,7 @@ private Viewer createView(EmailAccount account) {
}
- private static class SendTestEmailAction extends AbstractAction {
+ private static class SendTestEmailAction extends AbstractLocalAction {
private EmailService service;
diff --git a/extensions/entity-files/packages/files-sdk/README.md b/extensions/entity-files/packages/files-sdk/README.md
new file mode 100644
index 00000000..175833a4
--- /dev/null
+++ b/extensions/entity-files/packages/files-sdk/README.md
@@ -0,0 +1,155 @@
+# @dynamia-tools/files-sdk
+
+> TypeScript / JavaScript client SDK for the Dynamia Entity Files extension REST API.
+
+`@dynamia-tools/files-sdk` provides a small, focused client to download files managed by the Entity Files extension of a Dynamia Platform backend. The package exposes a single API class, `FilesApi`, which delegates HTTP, authentication and error handling to the core `@dynamia-tools/sdk` `HttpClient`.
+
+This README explains how to install the package, how to use `FilesApi` (recommended via `DynamiaClient`) and how to handle binary downloads in browser and Node.js environments.
+
+---
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Quick Start (recommended)](#quick-start-recommended)
+- [API methods](#api-methods)
+- [Browser example (download and show)](#browser-example-download-and-show)
+- [Node.js example (save to disk)](#nodejs-example-save-to-disk)
+- [Authentication & Errors](#authentication--errors)
+- [Contributing](#contributing)
+- [License](#license)
+
+---
+
+## Installation
+
+Install the package using your preferred package manager:
+
+```bash
+# pnpm (recommended)
+pnpm add @dynamia-tools/files-sdk
+
+# npm
+npm install @dynamia-tools/files-sdk
+
+# yarn
+yarn add @dynamia-tools/files-sdk
+```
+
+This package declares a peer dependency on `@dynamia-tools/sdk` (see `package.json`). The recommended pattern is to use the core SDK's `DynamiaClient` so you reuse the same HTTP client, auth configuration and fetch implementation.
+
+---
+
+## Quick Start (recommended)
+
+Construct `FilesApi` from an existing `DynamiaClient` so authentication, base URL and fetch are consistent:
+
+```text
+import { DynamiaClient } from '@dynamia-tools/sdk';
+import { FilesApi } from '@dynamia-tools/files-sdk';
+
+const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: '...' });
+const files = new FilesApi(client.http);
+
+// Download a file as a Blob (browser)
+const blob = await files.download('myfile.pdf', 'f9a3e8c2-...');
+
+// Get a direct URL (no network call performed)
+const url = files.getUrl('images/logo.png', 'f9a3e8c2-...');
+console.log(url);
+```
+
+Notes:
+- `FilesApi` methods are thin wrappers over the core `HttpClient` (`get`, `url`) implemented by `DynamiaClient`.
+- The `download()` method calls `GET /storage/{file}?uuid={uuid}` and returns a `Blob` in browser environments; when running in Node.js the underlying fetch polyfill may provide an `ArrayBuffer`/`Buffer` which you should convert to a file.
+
+---
+
+## API methods
+
+The implementation in `src/api.ts` exposes two methods on `FilesApi`:
+
+- `download(file: string, uuid: string): Promise`
+ - GET `/storage/{file}?uuid={uuid}` — Downloads the file. The SDK returns parsed JSON for `application/json` responses and a `Blob` for other content types (binary).
+- `getUrl(file: string, uuid: string): string`
+ - Returns a fully-qualified URL that points to `/storage/{file}` with the `uuid` query parameter. No HTTP request is made.
+
+Use `download()` when you need the file content programmatically (e.g., for preview or file save). Use `getUrl()` when you want to put a direct link in an `img` `src`, anchor `href`, or let the browser perform the download.
+
+---
+
+## Browser example (download and show)
+
+```text
+// show a downloaded PDF in a new tab
+const blob = await files.download('reports/monthly.pdf', 'f9a3e8c2-...');
+const url = URL.createObjectURL(blob);
+window.open(url, '_blank');
+// Remember to revoke the object URL when done
+URL.revokeObjectURL(url);
+```
+
+Use `files.getUrl('images/logo.png', uuid)` directly in ` ` if you just need to render an image.
+
+---
+
+## Node.js example (save to disk)
+
+When running in Node, the fetch implementation may not return a `Blob`. Use `arrayBuffer()` or convert to a `Buffer` before writing the file to disk:
+
+```text
+import fs from 'fs';
+import { DynamiaClient } from '@dynamia-tools/sdk';
+import { FilesApi } from '@dynamia-tools/files-sdk';
+
+const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: process.env.TOKEN, fetch: fetch });
+const files = new FilesApi(client.http);
+
+const res = await files.download('reports/monthly.pdf', 'f9a3e8c2-...');
+
+// If the returned value has arrayBuffer(), use it
+if (res && typeof (res as any).arrayBuffer === 'function') {
+ const ab = await (res as any).arrayBuffer();
+ const buffer = Buffer.from(ab);
+ await fs.promises.writeFile('./monthly.pdf', buffer);
+} else if (Buffer.isBuffer(res)) {
+ await fs.promises.writeFile('./monthly.pdf', res);
+} else {
+ // Fallback: inspect the object
+ console.error('Unexpected download result:', res);
+}
+```
+
+Note: if you are using Node.js < 18 you must provide a `fetch` implementation (e.g. `node-fetch`) and pass it to `DynamiaClient` via the `fetch` config option.
+
+---
+
+## Authentication & errors
+
+Use the same authentication methods supported by `DynamiaClient`:
+
+- Bearer token: pass `token` to `DynamiaClient`.
+- HTTP Basic: pass `username` and `password`.
+- Session / form-login: use `withCredentials: true` and perform the login flow before calling the SDK.
+
+Non-2xx responses are translated by the core `HttpClient` into `DynamiaApiError` (from `@dynamia-tools/sdk`). Catch this error to examine `status`, `url` and `body` for logging or user-friendly messages.
+
+---
+
+## Contributing
+
+See the repository-level `CONTRIBUTING.md` for contribution guidelines.
+
+Quick local steps:
+
+1. Clone the repo and install dependencies: `pnpm install`
+2. Work inside `framework/extensions/entity-files/packages/files-sdk/`
+3. Build and test: `pnpm --filter @dynamia-tools/files-sdk build` / `pnpm --filter @dynamia-tools/files-sdk test`
+
+---
+
+## License
+
+[Apache License 2.0](../../../../LICENSE) — © Dynamia Soluciones IT SAS
+
+
diff --git a/extensions/entity-files/packages/files-sdk/package.json b/extensions/entity-files/packages/files-sdk/package.json
new file mode 100644
index 00000000..6d1e181c
--- /dev/null
+++ b/extensions/entity-files/packages/files-sdk/package.json
@@ -0,0 +1,76 @@
+{
+ "name": "@dynamia-tools/files-sdk",
+ "version": "26.3.2",
+ "website": "https://dynamia.tools",
+ "description": "TypeScript/JavaScript client SDK for the Dynamia Entity Files extension REST API",
+ "keywords": [
+ "dynamia",
+ "dynamia-platform",
+ "entity-files",
+ "files",
+ "storage",
+ "sdk",
+ "rest",
+ "typescript"
+ ],
+ "homepage": "https://dynamia.tools",
+ "bugs": {
+ "url": "https://github.com/dynamiatools/framework/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/dynamiatools/framework.git",
+ "directory": "extensions/entity-files/packages/files-sdk"
+ },
+ "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": {
+ ".": {
+ "development": "./src/index.ts",
+ "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",
+ "test:coverage": "vitest run --coverage",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint src",
+ "clean": "rm -rf dist",
+ "prepublishOnly": "pnpm run build && pnpm run test"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "peerDependencies": {
+ "@dynamia-tools/sdk": ">=26.3.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",
+ "@vitest/coverage-v8": "^3.0.0"
+ }
+}
+
diff --git a/platform/packages/sdk/src/files/api.ts b/extensions/entity-files/packages/files-sdk/src/api.ts
similarity index 85%
rename from platform/packages/sdk/src/files/api.ts
rename to extensions/entity-files/packages/files-sdk/src/api.ts
index 09019200..ad3fa62d 100644
--- a/platform/packages/sdk/src/files/api.ts
+++ b/extensions/entity-files/packages/files-sdk/src/api.ts
@@ -1,7 +1,7 @@
-import type { HttpClient } from '../http.js';
+import type { HttpClient } from '@dynamia-tools/sdk';
/**
- * Download files managed by the entity-files extension.
+ * Download files managed by the Entity Files extension.
* Base path: /storage
*/
export class FilesApi {
@@ -27,3 +27,4 @@ export class FilesApi {
return this.http.url(`/storage/${encodeURIComponent(file)}`, { uuid });
}
}
+
diff --git a/platform/packages/sdk/src/files/index.ts b/extensions/entity-files/packages/files-sdk/src/index.ts
similarity index 97%
rename from platform/packages/sdk/src/files/index.ts
rename to extensions/entity-files/packages/files-sdk/src/index.ts
index 7a9e43d6..59d27c7d 100644
--- a/platform/packages/sdk/src/files/index.ts
+++ b/extensions/entity-files/packages/files-sdk/src/index.ts
@@ -1 +1,2 @@
export { FilesApi } from './api.js';
+
diff --git a/extensions/entity-files/packages/files-sdk/test/files.test.ts b/extensions/entity-files/packages/files-sdk/test/files.test.ts
new file mode 100644
index 00000000..203c861b
--- /dev/null
+++ b/extensions/entity-files/packages/files-sdk/test/files.test.ts
@@ -0,0 +1,14 @@
+import { describe, it, expect } from 'vitest';
+import { FilesApi } from '../src/index.js';
+import { mockFetch, makeHttpClient } from './helpers.js';
+
+describe('FilesApi', () => {
+ it('getUrl() returns a URL with uuid query param', () => {
+ const http = makeHttpClient(mockFetch(200, {}));
+ const api = new FilesApi(http);
+ const url = api.getUrl('photo.png', 'uuid-123');
+ expect(url).toContain('/storage/photo.png');
+ expect(url).toContain('uuid=uuid-123');
+ });
+});
+
diff --git a/extensions/entity-files/packages/files-sdk/test/helpers.ts b/extensions/entity-files/packages/files-sdk/test/helpers.ts
new file mode 100644
index 00000000..d23cabc2
--- /dev/null
+++ b/extensions/entity-files/packages/files-sdk/test/helpers.ts
@@ -0,0 +1,20 @@
+import { vi } from 'vitest';
+import { DynamiaClient, HttpClient } from '@dynamia-tools/sdk';
+
+export function mockFetch(status: number, body: unknown, contentType = 'application/json') {
+ return vi.fn().mockResolvedValue({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? 'OK' : 'Error',
+ headers: { get: (key: string) => (key === 'content-type' ? contentType : null) },
+ json: () => Promise.resolve(body),
+ text: () => Promise.resolve(String(body)),
+ blob: () => Promise.resolve(new Blob()),
+ } as unknown as Response);
+}
+
+export function makeHttpClient(fetchMock: ReturnType): HttpClient {
+ const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: 'test-token', fetch: fetchMock });
+ return client.http as HttpClient;
+}
+
diff --git a/extensions/entity-files/packages/files-sdk/tsconfig.build.json b/extensions/entity-files/packages/files-sdk/tsconfig.build.json
new file mode 100644
index 00000000..57115c9a
--- /dev/null
+++ b/extensions/entity-files/packages/files-sdk/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "noEmit": false,
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src"]
+}
+
diff --git a/extensions/entity-files/packages/files-sdk/tsconfig.json b/extensions/entity-files/packages/files-sdk/tsconfig.json
new file mode 100644
index 00000000..0f25062a
--- /dev/null
+++ b/extensions/entity-files/packages/files-sdk/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "customConditions": ["development"],
+ "noEmit": true
+ },
+ "include": ["src", "test"]
+}
+
diff --git a/extensions/entity-files/packages/files-sdk/vite.config.ts b/extensions/entity-files/packages/files-sdk/vite.config.ts
new file mode 100644
index 00000000..83c2b273
--- /dev/null
+++ b/extensions/entity-files/packages/files-sdk/vite.config.ts
@@ -0,0 +1,28 @@
+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: 'DynamiaFilesSdk',
+ formats: ['es', 'cjs'],
+ fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'),
+ },
+ rollupOptions: {
+ external: ['@dynamia-tools/sdk'],
+ },
+ sourcemap: true,
+ minify: false,
+ },
+});
+
diff --git a/extensions/entity-files/packages/files-sdk/vitest.config.ts b/extensions/entity-files/packages/files-sdk/vitest.config.ts
new file mode 100644
index 00000000..e356e449
--- /dev/null
+++ b/extensions/entity-files/packages/files-sdk/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: false,
+ environment: 'node',
+ },
+});
+
diff --git a/extensions/entity-files/sources/core/pom.xml b/extensions/entity-files/sources/core/pom.xml
index a628ed10..8242f69b 100644
--- a/extensions/entity-files/sources/core/pom.xml
+++ b/extensions/entity-files/sources/core/pom.xml
@@ -22,7 +22,7 @@
tools.dynamia.modules.entityfiles.parent
tools.dynamia.modules
- 26.3.1
+ 26.4.0
DynamiaModules - EntityFiles - Core
tools.dynamia.modules.entityfiles
@@ -54,20 +54,20 @@
tools.dynamia
tools.dynamia.domain.jpa
- 26.3.1
+ 26.4.0
jar
tools.dynamia
tools.dynamia.io
- 26.3.1
+ 26.4.0
jar
tools.dynamia
tools.dynamia.web
- 26.3.1
+ 26.4.0
jar
diff --git a/extensions/entity-files/sources/pom.xml b/extensions/entity-files/sources/pom.xml
index 62841618..c03f0453 100644
--- a/extensions/entity-files/sources/pom.xml
+++ b/extensions/entity-files/sources/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.parent
- 26.3.1
+ 26.4.0
../../pom.xml
diff --git a/extensions/entity-files/sources/s3/pom.xml b/extensions/entity-files/sources/s3/pom.xml
index b2769838..fdd35844 100644
--- a/extensions/entity-files/sources/s3/pom.xml
+++ b/extensions/entity-files/sources/s3/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.entityfiles.parent
- 26.3.1
+ 26.4.0
DynamiaModules - EntityFiles - S3
@@ -49,7 +49,7 @@
tools.dynamia.modules
tools.dynamia.modules.entityfiles
- 26.3.1
+ 26.4.0
software.amazon.awssdk
diff --git a/extensions/entity-files/sources/ui/pom.xml b/extensions/entity-files/sources/ui/pom.xml
index b05d72b5..a1669a7f 100644
--- a/extensions/entity-files/sources/ui/pom.xml
+++ b/extensions/entity-files/sources/ui/pom.xml
@@ -22,7 +22,7 @@
tools.dynamia.modules.entityfiles.parent
tools.dynamia.modules
- 26.3.1
+ 26.4.0
DynamiaModules - EntityFiles UI
tools.dynamia.modules.entityfiles.ui
@@ -48,12 +48,12 @@
tools.dynamia.modules
tools.dynamia.modules.entityfiles
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.zk
- 26.3.1
+ 26.4.0
jar
diff --git a/extensions/file-importer/sources/core/pom.xml b/extensions/file-importer/sources/core/pom.xml
index e35958c5..751ac78f 100644
--- a/extensions/file-importer/sources/core/pom.xml
+++ b/extensions/file-importer/sources/core/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules.importer.parent
tools.dynamia.modules
- 26.3.1
+ 26.4.0
Dynamia Modules - Importer Core
tools.dynamia.modules.importer
@@ -56,7 +56,7 @@
tools.dynamia
tools.dynamia.reports
- 26.3.1
+ 26.4.0
diff --git a/extensions/file-importer/sources/pom.xml b/extensions/file-importer/sources/pom.xml
index 1ec5e482..74d35983 100644
--- a/extensions/file-importer/sources/pom.xml
+++ b/extensions/file-importer/sources/pom.xml
@@ -26,7 +26,7 @@
tools.dynamia.modules
tools.dynamia.modules.parent
- 26.3.1
+ 26.4.0
../../pom.xml
diff --git a/extensions/file-importer/sources/ui/pom.xml b/extensions/file-importer/sources/ui/pom.xml
index 786b1689..7b0c8303 100644
--- a/extensions/file-importer/sources/ui/pom.xml
+++ b/extensions/file-importer/sources/ui/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules.importer.parent
tools.dynamia.modules
- 26.3.1
+ 26.4.0
Dynamia Modules - Importer UI
tools.dynamia.modules.importer.ui
@@ -55,13 +55,13 @@
tools.dynamia
tools.dynamia.zk
- 26.3.1
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.importer
- 26.3.1
+ 26.4.0
diff --git a/extensions/file-importer/sources/ui/src/main/java/tools/dynamia/modules/importer/ImportAction.java b/extensions/file-importer/sources/ui/src/main/java/tools/dynamia/modules/importer/ImportAction.java
index c7665803..059384c6 100644
--- a/extensions/file-importer/sources/ui/src/main/java/tools/dynamia/modules/importer/ImportAction.java
+++ b/extensions/file-importer/sources/ui/src/main/java/tools/dynamia/modules/importer/ImportAction.java
@@ -19,6 +19,7 @@
package tools.dynamia.modules.importer;
import tools.dynamia.actions.AbstractAction;
+import tools.dynamia.actions.AbstractLocalAction;
import tools.dynamia.actions.ActionEvent;
import tools.dynamia.actions.ActionRenderer;
import tools.dynamia.integration.ProgressMonitor;
@@ -29,7 +30,7 @@
*
* @author Mario Serrano Leones
*/
-public abstract class ImportAction extends AbstractAction {
+public abstract class ImportAction extends AbstractLocalAction {
private ProgressMonitor monitor;
private boolean procesable = true;
diff --git a/extensions/finances/sources/api/pom.xml b/extensions/finances/sources/api/pom.xml
index 1d2898e0..4c0ee885 100644
--- a/extensions/finances/sources/api/pom.xml
+++ b/extensions/finances/sources/api/pom.xml
@@ -26,7 +26,7 @@
tools.dynamia.modules
tools.dynamia.modules.finances.parent
- 26.3.1
+ 26.4.0
Dynamia Modules - Finances API
diff --git a/extensions/finances/sources/pom.xml b/extensions/finances/sources/pom.xml
index 48944906..81a0c965 100644
--- a/extensions/finances/sources/pom.xml
+++ b/extensions/finances/sources/pom.xml
@@ -26,7 +26,7 @@
tools.dynamia.modules
tools.dynamia.modules.parent
- 26.3.1
+ 26.4.0
../../pom.xml
diff --git a/extensions/pom.xml b/extensions/pom.xml
index 8fce81da..03d89c7b 100644
--- a/extensions/pom.xml
+++ b/extensions/pom.xml
@@ -6,7 +6,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../pom.xml
diff --git a/extensions/reports/packages/reports-sdk/README.md b/extensions/reports/packages/reports-sdk/README.md
new file mode 100644
index 00000000..054e32e2
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/README.md
@@ -0,0 +1,239 @@
+# @dynamia-tools/reports-sdk
+
+> Official TypeScript / JavaScript client SDK for the Dynamia Reports extension REST API.
+
+`@dynamia-tools/reports-sdk` provides a small, focused client to interact with the Reports extension of a Dynamia Platform backend. It exposes a single API class, `ReportsApi`, and the report-related TypeScript types. The implementation intentionally delegates HTTP, auth and error handling to the core `@dynamia-tools/sdk` `HttpClient`.
+
+This README documents how to install the package, how to integrate it with the core SDK and how to use the Reports API methods implemented in `src/api.ts`.
+
+---
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Quick Start](#quick-start)
+- [Direct usage notes](#direct-usage-notes)
+- [Reports API (methods & examples)](#reports-api)
+- [TypeScript types](#typescript-types)
+- [Handling exports / binary responses](#handling-exports--binary-responses)
+- [Authentication & errors](#authentication--errors)
+- [Contributing](#contributing)
+- [License](#license)
+
+---
+
+## Installation
+
+Install the package using your preferred package manager:
+
+```bash
+# pnpm (recommended)
+pnpm add @dynamia-tools/reports-sdk
+
+# npm
+npm install @dynamia-tools/reports-sdk
+
+# yarn
+yarn add @dynamia-tools/reports-sdk
+```
+
+This package declares a peer dependency on `@dynamia-tools/sdk` (see `package.json`). The recommended way to use `ReportsApi` is together with the core `DynamiaClient` so the HTTP client, authentication and fetch implementation are configured consistently across APIs.
+
+---
+
+## Quick Start
+
+The core SDK (`@dynamia-tools/sdk`) constructs and configures an `HttpClient` that handles base URL, authentication headers, fetch implementation and error handling. The `ReportsApi` expects an instance compatible with that `HttpClient` and is therefore typically constructed from an existing `DynamiaClient`:
+
+```ts
+import { DynamiaClient } from '@dynamia-tools/sdk';
+import { ReportsApi } from '@dynamia-tools/reports-sdk';
+
+// Create the core client (handles fetch, token/basic auth, cookies)
+const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: '...' });
+
+// Construct the ReportsApi using the client's internal http helper
+const reports = new ReportsApi(client.http);
+
+// List available reports
+const allReports = await reports.list();
+console.log(allReports.map(r => `${r.group}/${r.endpoint} → ${r.title ?? r.name}`));
+
+// Run a report by POSTing structured options (see types below)
+const runResult = await reports.post('sales', 'monthly', { options: [{ name: 'year', value: '2026' }] });
+console.log(runResult);
+
+// Or fetch using query-string params (GET)
+const table = await reports.get('sales', 'monthly', { year: 2026, region: 'EMEA' });
+console.log(table);
+```
+
+Notes:
+- Constructing `ReportsApi` with `client.http` is the supported pattern: the `HttpClient` instance takes care of Content-Type negotiation (JSON vs binary), error translation to `DynamiaApiError`, and credentials.
+- The `post()` method is the structured execution endpoint (POST body uses `ReportFilters.options` — see types). The `get()` method passes simple query-string parameters.
+
+---
+
+## Direct usage notes
+
+Although `ReportsApi` only requires an object that matches the `HttpClient` interface, the SDK authors intentionally rely on the `DynamiaClient`-provided `http` instance to ensure consistent behavior. If you need to use `ReportsApi` without `DynamiaClient`, you must provide a compatible `HttpClient` (a wrapper around `fetch` that implements `get`, `post`, `put`, `delete` and the same error semantics).
+
+In Node.js you will typically use `node-fetch` (or a global `fetch` polyfill) and construct a `DynamiaClient` from `@dynamia-tools/sdk` rather than reimplementing the HTTP helpers yourself.
+
+---
+
+## Reports API
+
+The `ReportsApi` class mirrors the implementation in `src/api.ts` and exposes the following methods (base path: `/api/reports`):
+
+- `list(): Promise`
+ - GET `/api/reports` — Returns a list of available reports with metadata and filters.
+- `get(group: string, endpoint: string, params?: Record): Promise`
+ - GET `/api/reports/{group}/{endpoint}` — Fetch report data by passing query-string parameters.
+- `post(group: string, endpoint: string, filters?: ReportFilters): Promise`
+ - POST `/api/reports/{group}/{endpoint}` — Execute a report using a structured body. Note: the package's types declare `ReportFilters.options: ReportFilterOption[]` (the Java model names this field `options`).
+
+Examples:
+
+- List reports and inspect available filters
+
+```ts
+const reportsList = await reports.list();
+for (const r of reportsList) {
+ console.log(r.group, r.endpoint, r.title ?? r.name);
+ if (r.filters?.length) {
+ console.log(' filters:', r.filters.map(f => `${f.name}${f.required ? ' (required)' : ''}`));
+ }
+}
+```
+
+- Execute a report using structured options (recommended when the report expects typed inputs)
+
+```ts
+// ReportFilters uses `options: { name, value }[]`
+const filters = { options: [{ name: 'startDate', value: '2026-01-01' }, { name: 'endDate', value: '2026-01-31' }] };
+const result = await reports.post('sales', 'monthly', filters);
+// `result` shape depends on the server-side report implementation (JSON table, aggregated object, etc.)
+console.log(result);
+```
+
+- Fetch a report via GET with simple query params
+
+```ts
+const table = await reports.get('sales', 'monthly', { year: 2026, region: 'EMEA' });
+console.log(table);
+```
+
+Because the SDK's `HttpClient` inspects `Content-Type`, these methods will return JSON when the server replies with `application/json` and will return a `Blob` when the server returns binary content (see next section).
+
+---
+
+## TypeScript types
+
+This package exports a small set of types that mirror the server models. The most important are:
+
+- `ReportDTO` — report descriptor returned by `list()`; contains `name`, `endpoint`, `group`, optional `filters` (array of `ReportFilterDTO`) and human-readable metadata.
+- `ReportFilterDTO` — metadata describing a single filter (name, datatype, required, values, format).
+- `ReportFilterOption` — a resolved filter option `{ name: string; value: string }` used when submitting a report.
+- `ReportFilters` — POST body shape `{ options: ReportFilterOption[] }`.
+
+Refer to the package exports (or your editor's intellisense) for exact types and nullable fields. Example (from `src/types.ts`):
+
+```ts
+// POST body
+interface ReportFilters { options: { name: string; value: string }[] }
+
+// Report descriptor
+interface ReportDTO { name: string; endpoint: string; group?: string; filters?: ReportFilterDTO[] }
+```
+
+---
+
+## Handling exports / binary responses
+
+The underlying `HttpClient` normalises responses as follows (see `@dynamia-tools/sdk` implementation):
+
+- If the response `Content-Type` includes `application/json` the client returns the parsed JSON.
+- Otherwise the client returns a `Blob` (binary response). In browsers you can turn that into a downloadable file; in Node.js you may need to use `arrayBuffer()` and write the buffer to disk.
+
+Browser example (download PDF):
+
+```ts
+const blob = await reports.post('sales', 'monthly-export', { options: [] });
+// If the server returned binary data this will be a Blob
+if (blob instanceof Blob) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'report.pdf';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+} else {
+ // JSON result (e.g. preview data)
+ console.log(blob);
+}
+```
+
+Node.js note: when running under Node you will typically use a `fetch` implementation that returns a Response whose `blob()` or `arrayBuffer()` is available. If you receive an `ArrayBuffer` or a Node `Buffer` you can write it to disk with `fs.writeFile`.
+
+Example using `arrayBuffer()` (generic):
+
+```ts
+const maybeBlob = await reports.post('sales', 'monthly-export', { options: [] });
+if ((maybeBlob as any).arrayBuffer) {
+ const ab = await (maybeBlob as any).arrayBuffer();
+ const buffer = Buffer.from(ab);
+ await fs.promises.writeFile('./monthly.pdf', buffer);
+} else if (maybeBlob instanceof Buffer) {
+ await fs.promises.writeFile('./monthly.pdf', maybeBlob as Buffer);
+} else {
+ console.log('non-binary response:', maybeBlob);
+}
+```
+
+---
+
+## Authentication & errors
+
+When you use the `DynamiaClient` the authentication strategies and error handling are handled by the core SDK:
+
+- Provide `token` for bearer auth (recommended), or `username`/`password` for Basic auth, or `withCredentials: true` for cookie-based form login.
+- Non-2xx responses are translated to `DynamiaApiError` (from `@dynamia-tools/sdk`). Catch this error to inspect `status`, `url` and `body`.
+
+Example:
+
+```ts
+import { DynamiaApiError } from '@dynamia-tools/sdk';
+
+try {
+ await reports.post('unknown', 'endpoint', { options: [] });
+} catch (err) {
+ if (err instanceof DynamiaApiError) {
+ console.error(`API error [${err.status}] ${err.message}`, err.body);
+ } else {
+ throw err;
+ }
+}
+```
+
+---
+
+## Contributing
+
+See the repository-level `CONTRIBUTING.md` for contribution guidelines.
+
+Quick local steps:
+
+1. Clone the monorepo and install: `pnpm install`
+2. Work inside `extensions/reports/packages/reports-sdk/`
+3. Build and test: `pnpm --filter @dynamia-tools/reports-sdk build` / `pnpm --filter @dynamia-tools/reports-sdk test`
+
+---
+
+## License
+
+[Apache License 2.0](../../../../LICENSE) — © Dynamia Soluciones IT SAS
+
+
diff --git a/extensions/reports/packages/reports-sdk/package.json b/extensions/reports/packages/reports-sdk/package.json
new file mode 100644
index 00000000..a7c1e94b
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@dynamia-tools/reports-sdk",
+ "version": "26.3.2",
+ "website": "https://dynamia.tools",
+ "description": "TypeScript/JavaScript client SDK for the Dynamia Reports extension REST API",
+ "keywords": [
+ "dynamia",
+ "dynamia-platform",
+ "reports",
+ "sdk",
+ "rest",
+ "typescript"
+ ],
+ "homepage": "https://dynamia.tools",
+ "bugs": {
+ "url": "https://github.com/dynamiatools/framework/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/dynamiatools/framework.git",
+ "directory": "extensions/reports/packages/reports-sdk"
+ },
+ "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": {
+ ".": {
+ "development": "./src/index.ts",
+ "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",
+ "test:coverage": "vitest run --coverage",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint src",
+ "clean": "rm -rf dist",
+ "prepublishOnly": "pnpm run build && pnpm run test"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "peerDependencies": {
+ "@dynamia-tools/sdk": ">=26.3.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",
+ "@vitest/coverage-v8": "^3.0.0"
+ }
+}
+
diff --git a/platform/packages/sdk/src/reports/api.ts b/extensions/reports/packages/reports-sdk/src/api.ts
similarity index 91%
rename from platform/packages/sdk/src/reports/api.ts
rename to extensions/reports/packages/reports-sdk/src/api.ts
index 2ac387ac..7a2cd559 100644
--- a/platform/packages/sdk/src/reports/api.ts
+++ b/extensions/reports/packages/reports-sdk/src/api.ts
@@ -1,10 +1,10 @@
-import type { HttpClient } from '../http.js';
+import type { HttpClient } from '@dynamia-tools/sdk';
import type { ReportDTO, ReportFilters } from './types.js';
type ReportGetParams = Record;
/**
- * Access the Reports extension.
+ * Access the Reports extension REST API.
* Base path: /api/reports
*/
export class ReportsApi {
@@ -35,3 +35,4 @@ export class ReportsApi {
);
}
}
+
diff --git a/platform/packages/sdk/src/reports/index.ts b/extensions/reports/packages/reports-sdk/src/index.ts
similarity index 71%
rename from platform/packages/sdk/src/reports/index.ts
rename to extensions/reports/packages/reports-sdk/src/index.ts
index 5347a5f9..3ae28ce8 100644
--- a/platform/packages/sdk/src/reports/index.ts
+++ b/extensions/reports/packages/reports-sdk/src/index.ts
@@ -1,6 +1,8 @@
export { ReportsApi } from './api.js';
export type {
ReportDTO,
- ReportFilter,
+ ReportFilterDTO,
+ ReportFilterOption,
ReportFilters,
} from './types.js';
+
diff --git a/extensions/reports/packages/reports-sdk/src/types.ts b/extensions/reports/packages/reports-sdk/src/types.ts
new file mode 100644
index 00000000..69d0de8c
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/src/types.ts
@@ -0,0 +1,72 @@
+// ─── Reports types mirroring the Dynamia Platform Java model ────────────────
+
+/**
+ * Descriptor for a single filter field within a report.
+ *
+ * Mirrors `tools.dynamia.reports.ReportFilterDTO` — returned by `GET /api/reports`
+ * inside `ReportDTO.filters`. Describes the metadata of a filter input
+ * (label, datatype, whether it is required, etc.).
+ */
+export interface ReportFilterDTO {
+ /** Parameter name used as the key when submitting the report */
+ name: string;
+ /** Java datatype hint (e.g. `"java.time.LocalDate"`, `"java.lang.String"`) */
+ datatype?: string;
+ /** Human-readable label shown in the UI */
+ label?: string;
+ /** Whether this filter must be provided before the report can be run */
+ required?: boolean;
+ /** Predefined selectable values (used for enum / select-type filters) */
+ values?: string[];
+ /** Date/number format pattern for parsing the value */
+ format?: string;
+}
+
+/**
+ * A single resolved filter value to be sent as part of a report execution request.
+ *
+ * Mirrors `tools.dynamia.reports.ReportFilterOption` — used as items in the
+ * `options` array of `ReportFilters` (the POST body for report execution).
+ */
+export interface ReportFilterOption {
+ /** Filter parameter name (matches `ReportFilterDTO.name`) */
+ name: string;
+ /** Resolved string value for this filter */
+ value: string;
+}
+
+/**
+ * POST body sent to the report execution endpoint.
+ *
+ * Mirrors `tools.dynamia.reports.ReportFilters`.
+ * The field is named `options` in Java (`List options`) —
+ * NOT `filters`.
+ */
+export interface ReportFilters {
+ options: ReportFilterOption[];
+}
+
+/**
+ * Report descriptor returned by `GET /api/reports`.
+ *
+ * Mirrors `tools.dynamia.reports.ReportDTO`.
+ * Optional fields are absent from the JSON when the report has not configured them.
+ */
+export interface ReportDTO {
+ /** Unique report identifier */
+ id?: string;
+ /** Internal report name / key */
+ name: string;
+ /** Display title of the report */
+ title?: string;
+ /** Optional subtitle shown below the title */
+ subtitle?: string;
+ /** Human-readable description of what the report contains */
+ description?: string;
+ /** Grouping category for the report */
+ group?: string;
+ /** REST endpoint used to execute / download this report */
+ endpoint: string;
+ /** List of filter descriptors accepted by this report */
+ filters?: ReportFilterDTO[];
+}
diff --git a/extensions/reports/packages/reports-sdk/test/helpers.ts b/extensions/reports/packages/reports-sdk/test/helpers.ts
new file mode 100644
index 00000000..d23cabc2
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/test/helpers.ts
@@ -0,0 +1,20 @@
+import { vi } from 'vitest';
+import { DynamiaClient, HttpClient } from '@dynamia-tools/sdk';
+
+export function mockFetch(status: number, body: unknown, contentType = 'application/json') {
+ return vi.fn().mockResolvedValue({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? 'OK' : 'Error',
+ headers: { get: (key: string) => (key === 'content-type' ? contentType : null) },
+ json: () => Promise.resolve(body),
+ text: () => Promise.resolve(String(body)),
+ blob: () => Promise.resolve(new Blob()),
+ } as unknown as Response);
+}
+
+export function makeHttpClient(fetchMock: ReturnType): HttpClient {
+ const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: 'test-token', fetch: fetchMock });
+ return client.http as HttpClient;
+}
+
diff --git a/extensions/reports/packages/reports-sdk/test/reports.test.ts b/extensions/reports/packages/reports-sdk/test/reports.test.ts
new file mode 100644
index 00000000..65dea165
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/test/reports.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect, vi } from 'vitest';
+import { ReportsApi } from '../src/index.js';
+import { mockFetch, makeHttpClient } from './helpers.js';
+
+describe('ReportsApi', () => {
+ it('list() calls GET /api/reports', async () => {
+ const reports = [{ name: 'sales', endpoint: 'sales/monthly' }];
+ const fetch = mockFetch(200, reports);
+ const api = new ReportsApi(makeHttpClient(fetch));
+ const result = await api.list();
+ expect(result).toEqual(reports);
+ expect(fetch).toHaveBeenCalledOnce();
+ });
+
+ it('post() calls POST /api/reports/{group}/{endpoint}', async () => {
+ const fetch = mockFetch(200, { rows: [] });
+ const api = new ReportsApi(makeHttpClient(fetch));
+ await api.post('sales', 'monthly', { options: [{ name: 'year', value: '2025' }] });
+ const [url] = fetch.mock.calls[0] as [string, RequestInit];
+ expect(url).toContain('/api/reports/sales/monthly');
+ });
+});
+
diff --git a/extensions/reports/packages/reports-sdk/tsconfig.build.json b/extensions/reports/packages/reports-sdk/tsconfig.build.json
new file mode 100644
index 00000000..57115c9a
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "noEmit": false,
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src"]
+}
+
diff --git a/extensions/reports/packages/reports-sdk/tsconfig.json b/extensions/reports/packages/reports-sdk/tsconfig.json
new file mode 100644
index 00000000..0f25062a
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "customConditions": ["development"],
+ "noEmit": true
+ },
+ "include": ["src", "test"]
+}
+
diff --git a/extensions/reports/packages/reports-sdk/vite.config.ts b/extensions/reports/packages/reports-sdk/vite.config.ts
new file mode 100644
index 00000000..d57a0268
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/vite.config.ts
@@ -0,0 +1,28 @@
+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: 'DynamiaReportsSdk',
+ formats: ['es', 'cjs'],
+ fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'),
+ },
+ rollupOptions: {
+ external: ['@dynamia-tools/sdk'],
+ },
+ sourcemap: true,
+ minify: false,
+ },
+});
+
diff --git a/extensions/reports/packages/reports-sdk/vitest.config.ts b/extensions/reports/packages/reports-sdk/vitest.config.ts
new file mode 100644
index 00000000..e356e449
--- /dev/null
+++ b/extensions/reports/packages/reports-sdk/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: false,
+ environment: 'node',
+ },
+});
+
diff --git a/extensions/reports/sources/api/pom.xml b/extensions/reports/sources/api/pom.xml
index 53830ead..391a617f 100644
--- a/extensions/reports/sources/api/pom.xml
+++ b/extensions/reports/sources/api/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.reports.parent
- 26.3.1
+ 26.4.0
DynamiaModules - Reports API
diff --git a/extensions/reports/sources/core/pom.xml b/extensions/reports/sources/core/pom.xml
index 70ee3f77..d9109c32 100644
--- a/extensions/reports/sources/core/pom.xml
+++ b/extensions/reports/sources/core/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.reports.parent
- 26.3.1
+ 26.4.0
DynamiaModules - Reports Core
@@ -50,17 +50,17 @@
tools.dynamia.modules
tools.dynamia.modules.reports.api
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.domain.jpa
- 26.3.1
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.saas.jpa
- 26.3.1
+ 26.4.0
org.springframework
@@ -69,12 +69,12 @@
tools.dynamia
tools.dynamia.reports
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.templates
- 26.3.1
+ 26.4.0
compile
diff --git a/extensions/reports/sources/pom.xml b/extensions/reports/sources/pom.xml
index a65743da..5cc490e4 100644
--- a/extensions/reports/sources/pom.xml
+++ b/extensions/reports/sources/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.parent
- 26.3.1
+ 26.4.0
../../pom.xml
diff --git a/extensions/reports/sources/ui/pom.xml b/extensions/reports/sources/ui/pom.xml
index 240d186f..041812c4 100644
--- a/extensions/reports/sources/ui/pom.xml
+++ b/extensions/reports/sources/ui/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.reports.parent
- 26.3.1
+ 26.4.0
DynamiaModules - Reports UI
@@ -49,17 +49,17 @@
tools.dynamia.modules
tools.dynamia.modules.reports.core
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.zk
- 26.3.1
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.dashboard
- 26.3.1
+ 26.4.0
io.swagger.core.v3
diff --git a/extensions/saas/packages/saas-sdk/README.md b/extensions/saas/packages/saas-sdk/README.md
new file mode 100644
index 00000000..dd48d317
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/README.md
@@ -0,0 +1,160 @@
+# @dynamia-tools/saas-sdk
+
+> TypeScript / JavaScript client SDK for the Dynamia SaaS extension REST API.
+
+`@dynamia-tools/saas-sdk` provides a small, focused client to interact with the SaaS (multi-tenant) extension of a Dynamia Platform backend. It exposes `SaasApi` for common SaaS operations and the `AccountDTO` type.
+
+The package is intentionally minimal: it delegates HTTP, authentication and error handling to the core `@dynamia-tools/sdk` `HttpClient`. The recommended usage is to construct `SaasApi` from an existing `DynamiaClient` (`client.http`).
+
+---
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Quick Start](#quick-start)
+- [Direct usage without `DynamiaClient`](#direct-usage-without-dynamiaclient)
+- [API Reference](#api-reference)
+- [Types](#types)
+- [Authentication & Error Handling](#authentication--error-handling)
+- [Contributing](#contributing)
+- [License](#license)
+
+---
+
+## Installation
+
+Install the package with your preferred package manager:
+
+```bash
+# pnpm (recommended)
+pnpm add @dynamia-tools/saas-sdk
+
+# npm
+npm install @dynamia-tools/saas-sdk
+
+# yarn
+yarn add @dynamia-tools/saas-sdk
+```
+
+Note: `@dynamia-tools/saas-sdk` declares a peer dependency on `@dynamia-tools/sdk`. The recommended pattern is to install and use the core SDK as well so you can reuse its `DynamiaClient` and `HttpClient`.
+
+---
+
+## Quick Start
+
+The easiest and most robust way to use the SaaS API is via the core `DynamiaClient`. The `DynamiaClient` sets up fetch, authentication headers and error handling; pass its `http` instance to `SaasApi`:
+
+```ts
+import { DynamiaClient, DynamiaApiError } from '@dynamia-tools/sdk';
+import { SaasApi } from '@dynamia-tools/saas-sdk';
+
+// Create the core client with baseUrl and token/basic auth
+const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: 'your-bearer-token' });
+
+// Construct the SaaS API using the client's HttpClient
+const saas = new SaasApi(client.http);
+
+// Fetch account information by UUID
+try {
+ const account = await saas.getAccount('f9a3e8c2-...');
+ console.log(account.name, account.status, account.subdomain ?? 'no subdomain');
+} catch (err) {
+ if (err instanceof DynamiaApiError) {
+ console.error(`API error [${err.status}] ${err.message}`, err.body);
+ } else {
+ throw err;
+ }
+}
+```
+
+This uses the real `SaasApi` method implemented in `src/api.ts`: `getAccount(uuid: string): Promise`
+
+---
+
+## Direct usage without `DynamiaClient`
+
+If you do not want to instantiate `DynamiaClient`, you may provide any compatible `HttpClient` implementation that implements `get`, `post`, `put`, `delete`, and follows the same error semantics. In Node.js this usually means wrapping `node-fetch` and implementing the small helper used by the other SDK packages — however, using `DynamiaClient` is simpler and recommended.
+
+---
+
+## API Reference
+
+### SaasApi
+
+Construct with an `HttpClient` (typically `client.http` from `DynamiaClient`).
+
+- `getAccount(uuid: string): Promise`
+ - GET `/api/saas/account/{uuid}` — Returns account information for the given UUID.
+
+Example:
+
+```ts
+const account = await saas.getAccount('f9a3e8c2-...');
+console.log(account);
+```
+
+---
+
+## Types
+
+Important types are exported from this package's `src/types.ts`.
+
+`AccountDTO` (excerpt)
+
+```ts
+interface AccountDTO {
+ id: number;
+ uuid: string;
+ name: string;
+ status: string;
+ statusDescription?: string;
+ subdomain?: string;
+ email?: string;
+ statusDate?: string;
+ expirationDate?: string;
+ locale?: string;
+ timeZone?: string;
+ customerId?: string;
+ planId?: string;
+ planName?: string;
+}
+```
+
+Import types together with the API:
+
+```ts
+import { SaasApi } from '@dynamia-tools/saas-sdk';
+import type { AccountDTO } from '@dynamia-tools/saas-sdk';
+```
+
+---
+
+## Authentication & Error Handling
+
+Authentication is handled by the `HttpClient` provided by `DynamiaClient`. Supported strategies:
+
+- Bearer token: pass `token` to `DynamiaClient`.
+- HTTP Basic: pass `username` and `password` to `DynamiaClient`.
+- Cookie / Form login: use `withCredentials: true` and perform login via HTTP before calling the SDK.
+
+Errors: the core `HttpClient` maps non-2xx responses to `DynamiaApiError` which contains `status`, `url` and `body`. Catch `DynamiaApiError` to implement centralized logging or UI-friendly error messages.
+
+---
+
+## Contributing
+
+See the repository-level `CONTRIBUTING.md` for contribution guidelines.
+
+Quick local steps:
+
+1. Clone the repo and install dependencies: `pnpm install`
+2. Work inside `extensions/saas/packages/saas-sdk/`
+3. Build and test: `pnpm --filter @dynamia-tools/saas-sdk build` / `pnpm --filter @dynamia-tools/saas-sdk test`
+
+---
+
+## License
+
+[Apache License 2.0](../../../../LICENSE) — © Dynamia Soluciones IT SAS
+
+
diff --git a/extensions/saas/packages/saas-sdk/package.json b/extensions/saas/packages/saas-sdk/package.json
new file mode 100644
index 00000000..d11eccdf
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/package.json
@@ -0,0 +1,75 @@
+{
+ "name": "@dynamia-tools/saas-sdk",
+ "version": "26.3.2",
+ "website": "https://dynamia.tools",
+ "description": "TypeScript/JavaScript client SDK for the Dynamia SaaS extension REST API",
+ "keywords": [
+ "dynamia",
+ "dynamia-platform",
+ "saas",
+ "multi-tenant",
+ "sdk",
+ "rest",
+ "typescript"
+ ],
+ "homepage": "https://dynamia.tools",
+ "bugs": {
+ "url": "https://github.com/dynamiatools/framework/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/dynamiatools/framework.git",
+ "directory": "extensions/saas/packages/saas-sdk"
+ },
+ "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": {
+ ".": {
+ "development": "./src/index.ts",
+ "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",
+ "test:coverage": "vitest run --coverage",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint src",
+ "clean": "rm -rf dist",
+ "prepublishOnly": "pnpm run build && pnpm run test"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "peerDependencies": {
+ "@dynamia-tools/sdk": ">=26.3.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",
+ "@vitest/coverage-v8": "^3.0.0"
+ }
+}
+
diff --git a/platform/packages/sdk/src/saas/api.ts b/extensions/saas/packages/saas-sdk/src/api.ts
similarity index 77%
rename from platform/packages/sdk/src/saas/api.ts
rename to extensions/saas/packages/saas-sdk/src/api.ts
index 2800b907..0e5b5af7 100644
--- a/platform/packages/sdk/src/saas/api.ts
+++ b/extensions/saas/packages/saas-sdk/src/api.ts
@@ -1,8 +1,8 @@
-import type { HttpClient } from '../http.js';
+import type { HttpClient } from '@dynamia-tools/sdk';
import type { AccountDTO } from './types.js';
/**
- * Manage multi-tenant accounts via the SaaS extension.
+ * Manage multi-tenant accounts via the SaaS extension REST API.
* Base path: /api/saas
*/
export class SaasApi {
@@ -17,3 +17,4 @@ export class SaasApi {
return this.http.get(`/api/saas/account/${encodeURIComponent(uuid)}`);
}
}
+
diff --git a/platform/packages/sdk/src/saas/index.ts b/extensions/saas/packages/saas-sdk/src/index.ts
similarity index 98%
rename from platform/packages/sdk/src/saas/index.ts
rename to extensions/saas/packages/saas-sdk/src/index.ts
index a358477b..b633154c 100644
--- a/platform/packages/sdk/src/saas/index.ts
+++ b/extensions/saas/packages/saas-sdk/src/index.ts
@@ -1,2 +1,3 @@
export { SaasApi } from './api.js';
export type { AccountDTO } from './types.js';
+
diff --git a/extensions/saas/packages/saas-sdk/src/types.ts b/extensions/saas/packages/saas-sdk/src/types.ts
new file mode 100644
index 00000000..ec6979de
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/src/types.ts
@@ -0,0 +1,39 @@
+// ─── SaaS types mirroring the Dynamia Platform Java model ───────────────────
+
+/**
+ * Account data transfer object returned by the SaaS API.
+ *
+ * Mirrors `tools.dynamia.saas.AccountDTO`.
+ * Fields annotated `@JsonInclude(NON_NULL)` in Java will be absent from the
+ * JSON payload when they have not been set on the account.
+ */
+export interface AccountDTO {
+ /** Internal numeric account ID */
+ id: number;
+ /** Stable UUID identifying the account */
+ uuid: string;
+ /** Account / company display name */
+ name: string;
+ /** Current lifecycle status key (e.g. `"ACTIVE"`, `"SUSPENDED"`, `"TRIAL"`) */
+ status: string;
+ /** Human-readable description of the current status */
+ statusDescription?: string;
+ /** Subdomain assigned to this account (e.g. `"acme"` → `acme.example.com`) */
+ subdomain?: string;
+ /** Contact e-mail address for the account */
+ email?: string;
+ /** ISO date-time string when the current status was set */
+ statusDate?: string;
+ /** ISO date-time string when the account subscription expires */
+ expirationDate?: string;
+ /** BCP 47 locale code for the account (e.g. `"en_US"`, `"es_CO"`) */
+ locale?: string;
+ /** IANA time-zone ID (e.g. `"America/Bogota"`) */
+ timeZone?: string;
+ /** External customer ID from a billing / CRM system */
+ customerId?: string;
+ /** Identifier of the subscription plan assigned to this account */
+ planId?: string;
+ /** Display name of the subscription plan */
+ planName?: string;
+}
diff --git a/extensions/saas/packages/saas-sdk/test/helpers.ts b/extensions/saas/packages/saas-sdk/test/helpers.ts
new file mode 100644
index 00000000..d23cabc2
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/test/helpers.ts
@@ -0,0 +1,20 @@
+import { vi } from 'vitest';
+import { DynamiaClient, HttpClient } from '@dynamia-tools/sdk';
+
+export function mockFetch(status: number, body: unknown, contentType = 'application/json') {
+ return vi.fn().mockResolvedValue({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? 'OK' : 'Error',
+ headers: { get: (key: string) => (key === 'content-type' ? contentType : null) },
+ json: () => Promise.resolve(body),
+ text: () => Promise.resolve(String(body)),
+ blob: () => Promise.resolve(new Blob()),
+ } as unknown as Response);
+}
+
+export function makeHttpClient(fetchMock: ReturnType): HttpClient {
+ const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: 'test-token', fetch: fetchMock });
+ return client.http as HttpClient;
+}
+
diff --git a/extensions/saas/packages/saas-sdk/test/saas.test.ts b/extensions/saas/packages/saas-sdk/test/saas.test.ts
new file mode 100644
index 00000000..7c57af8c
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/test/saas.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from 'vitest';
+import { SaasApi } from '../src/index.js';
+import { mockFetch, makeHttpClient } from './helpers.js';
+
+describe('SaasApi', () => {
+ it('getAccount() calls GET /api/saas/account/{uuid}', async () => {
+ const account = { id: 1, uuid: 'abc-123', name: 'Acme Corp', status: 'ACTIVE' };
+ const fetch = mockFetch(200, account);
+ const api = new SaasApi(makeHttpClient(fetch));
+ const result = await api.getAccount('abc-123');
+ expect(result).toEqual(account);
+ const [url] = fetch.mock.calls[0] as [string, RequestInit];
+ expect(url).toContain('/api/saas/account/abc-123');
+ });
+});
+
diff --git a/extensions/saas/packages/saas-sdk/tsconfig.build.json b/extensions/saas/packages/saas-sdk/tsconfig.build.json
new file mode 100644
index 00000000..57115c9a
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "noEmit": false,
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src"]
+}
+
diff --git a/extensions/saas/packages/saas-sdk/tsconfig.json b/extensions/saas/packages/saas-sdk/tsconfig.json
new file mode 100644
index 00000000..0f25062a
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "customConditions": ["development"],
+ "noEmit": true
+ },
+ "include": ["src", "test"]
+}
+
diff --git a/extensions/saas/packages/saas-sdk/vite.config.ts b/extensions/saas/packages/saas-sdk/vite.config.ts
new file mode 100644
index 00000000..d874b918
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/vite.config.ts
@@ -0,0 +1,28 @@
+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: 'DynamiaSaasSdk',
+ formats: ['es', 'cjs'],
+ fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'),
+ },
+ rollupOptions: {
+ external: ['@dynamia-tools/sdk'],
+ },
+ sourcemap: true,
+ minify: false,
+ },
+});
+
diff --git a/extensions/saas/packages/saas-sdk/vitest.config.ts b/extensions/saas/packages/saas-sdk/vitest.config.ts
new file mode 100644
index 00000000..e356e449
--- /dev/null
+++ b/extensions/saas/packages/saas-sdk/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: false,
+ environment: 'node',
+ },
+});
+
diff --git a/extensions/saas/sources/api/pom.xml b/extensions/saas/sources/api/pom.xml
index f369a694..61c8e33c 100644
--- a/extensions/saas/sources/api/pom.xml
+++ b/extensions/saas/sources/api/pom.xml
@@ -26,7 +26,7 @@
tools.dynamia.modules
tools.dynamia.modules.saas.parent
- 26.3.1
+ 26.4.0
@@ -55,7 +55,7 @@
tools.dynamia
tools.dynamia.actions
- 26.3.1
+ 26.4.0
org.springframework.boot
diff --git a/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountAdminAction.java b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountAdminAction.java
index 4127eddf..ea9af09c 100644
--- a/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountAdminAction.java
+++ b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountAdminAction.java
@@ -18,6 +18,7 @@
package tools.dynamia.modules.saas.api;
import tools.dynamia.actions.AbstractAction;
+import tools.dynamia.actions.AbstractLocalAction;
import tools.dynamia.actions.ActionEvent;
import tools.dynamia.actions.ActionSelfFilter;
import tools.dynamia.integration.Containers;
@@ -52,7 +53,7 @@
* @see AccountAdminActionAuthorizationProvider
* @see AbstractAction
*/
-public abstract class AccountAdminAction extends AbstractAction implements ActionSelfFilter {
+public abstract class AccountAdminAction extends AbstractLocalAction implements ActionSelfFilter {
private boolean authorizationRequired;
diff --git a/extensions/saas/sources/core/pom.xml b/extensions/saas/sources/core/pom.xml
index 8382a385..c4224331 100644
--- a/extensions/saas/sources/core/pom.xml
+++ b/extensions/saas/sources/core/pom.xml
@@ -22,7 +22,7 @@
tools.dynamia.modules
tools.dynamia.modules.saas.parent
- 26.3.1
+ 26.4.0
DynamiaModules - SaaS Core
@@ -49,18 +49,18 @@
tools.dynamia.modules
tools.dynamia.modules.saas.api
- 26.3.1
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.saas.jpa
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.integration
- 26.3.1
+ 26.4.0
@@ -86,7 +86,7 @@
tools.dynamia.modules
tools.dynamia.modules.entityfiles
- 26.3.1
+ 26.4.0
org.hibernate.orm
diff --git a/extensions/saas/sources/jpa/pom.xml b/extensions/saas/sources/jpa/pom.xml
index f3594667..a66d1997 100644
--- a/extensions/saas/sources/jpa/pom.xml
+++ b/extensions/saas/sources/jpa/pom.xml
@@ -24,7 +24,7 @@
tools.dynamia.modules.saas.parent
tools.dynamia.modules
- 26.3.1
+ 26.4.0
DynamiaModules - SaaS JPA
@@ -35,12 +35,12 @@
tools.dynamia.modules
tools.dynamia.modules.saas.api
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.domain.jpa
- 26.3.1
+ 26.4.0
diff --git a/extensions/saas/sources/pom.xml b/extensions/saas/sources/pom.xml
index 0f989a18..448251cd 100644
--- a/extensions/saas/sources/pom.xml
+++ b/extensions/saas/sources/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.modules
tools.dynamia.modules.parent
- 26.3.1
+ 26.4.0
../../pom.xml
diff --git a/extensions/saas/sources/remote/pom.xml b/extensions/saas/sources/remote/pom.xml
index 97d49d96..9e849cb0 100644
--- a/extensions/saas/sources/remote/pom.xml
+++ b/extensions/saas/sources/remote/pom.xml
@@ -25,7 +25,7 @@
tools.dynamia.modules.saas.parent
tools.dynamia.modules
- 26.3.1
+ 26.4.0
@@ -38,7 +38,7 @@
tools.dynamia.modules
tools.dynamia.modules.saas.jpa
- 26.3.1
+ 26.4.0
diff --git a/extensions/saas/sources/ui/pom.xml b/extensions/saas/sources/ui/pom.xml
index 03020f00..6bde3bc2 100644
--- a/extensions/saas/sources/ui/pom.xml
+++ b/extensions/saas/sources/ui/pom.xml
@@ -22,7 +22,7 @@
tools.dynamia.modules
tools.dynamia.modules.saas.parent
- 26.3.1
+ 26.4.0
DynamiaModules - SaaS UI
tools.dynamia.modules.saas.ui
@@ -54,12 +54,12 @@
tools.dynamia
tools.dynamia.zk
- 26.3.1
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.saas
- 26.3.1
+ 26.4.0
@@ -70,7 +70,7 @@
tools.dynamia.modules
tools.dynamia.modules.entityfiles.ui
- 26.3.1
+ 26.4.0
diff --git a/extensions/security/sources/core/pom.xml b/extensions/security/sources/core/pom.xml
index 6dbbffe6..abd650b8 100644
--- a/extensions/security/sources/core/pom.xml
+++ b/extensions/security/sources/core/pom.xml
@@ -17,7 +17,7 @@
tools.dynamia.modules
tools.dynamia.modules.security.parent
- 26.3.1
+ 26.4.0
4.0.0
@@ -32,34 +32,34 @@
tools.dynamia.modules
tools.dynamia.modules.saas.api
- 26.3.1
+ 26.4.0
tools.dynamia.modules
tools.dynamia.modules.entityfiles
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.domain.jpa
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.domain
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.integration
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.web
- 26.3.1
+ 26.4.0
diff --git a/extensions/security/sources/pom.xml b/extensions/security/sources/pom.xml
index 843d051f..debd67e0 100644
--- a/extensions/security/sources/pom.xml
+++ b/extensions/security/sources/pom.xml
@@ -19,7 +19,7 @@
tools.dynamia.modules
tools.dynamia.modules.parent
- 26.3.1
+ 26.4.0
../../pom.xml
diff --git a/extensions/security/sources/ui/pom.xml b/extensions/security/sources/ui/pom.xml
index 0aeaff55..6f6eb733 100644
--- a/extensions/security/sources/ui/pom.xml
+++ b/extensions/security/sources/ui/pom.xml
@@ -17,7 +17,7 @@
tools.dynamia.modules
tools.dynamia.modules.security.parent
- 26.3.1
+ 26.4.0
DynamiaModules - Security UI
@@ -44,18 +44,18 @@
tools.dynamia.modules
tools.dynamia.modules.security
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.zk
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.app
- 26.3.1
+ 26.4.0
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..e748df62
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@dynamia-tools/workspace",
+ "version": "26.3.2",
+ "private": true,
+ "author": "Mario Serrano",
+ "repository": "https://github.com/dynamiatools/framework",
+ "website": "https://dynamia.tools",
+ "description": "pnpm monorepo – all Dynamia Tools TypeScript/JavaScript packages",
+ "scripts": {
+ "build": "pnpm -r run build",
+ "test": "pnpm -r run test",
+ "lint": "pnpm -r run lint",
+ "clean": "pnpm -r run clean",
+ "typecheck": "pnpm -r run typecheck",
+ "publish:all": "pnpm -r publish --access public --no-git-checks"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.2.0",
+ "vite-plugin-dts": "^4.5.0",
+ "vitest": "^3.0.0",
+ "@vitest/coverage-v8": "^3.0.0",
+ "eslint": "^9.0.0",
+ "prettier": "^3.5.0"
+ },
+ "engines": {
+ "node": ">=20",
+ "pnpm": ">=9"
+ },
+ "packageManager": "pnpm@10.31.0",
+ "license": "Apache-2.0"
+}
+
diff --git a/platform/app/pom.xml b/platform/app/pom.xml
index b0660e7c..3a01b363 100644
--- a/platform/app/pom.xml
+++ b/platform/app/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../pom.xml
@@ -74,58 +74,58 @@
tools.dynamia
tools.dynamia.actions
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.commons
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.crud
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.domain
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.integration
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.io
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.navigation
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.reports
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.templates
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.viewers
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.web
- 26.3.1
+ 26.4.0
@@ -205,7 +205,7 @@
tools.dynamia
tools.dynamia.domain.jpa
- 26.3.1
+ 26.4.0
test
diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/ApplicationMetadataController.java b/platform/app/src/main/java/tools/dynamia/app/controllers/ApplicationMetadataController.java
index 299a8d23..76bbc721 100644
--- a/platform/app/src/main/java/tools/dynamia/app/controllers/ApplicationMetadataController.java
+++ b/platform/app/src/main/java/tools/dynamia/app/controllers/ApplicationMetadataController.java
@@ -4,17 +4,20 @@
import org.springframework.context.annotation.DependsOn;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
-import tools.dynamia.actions.ActionExecutionRequest;
-import tools.dynamia.actions.ActionExecutionResponse;
-import tools.dynamia.actions.ActionRestrictions;
-import tools.dynamia.actions.Actions;
+import tools.dynamia.actions.*;
import tools.dynamia.app.metadata.*;
-import tools.dynamia.commons.SimpleCache;
+import tools.dynamia.commons.ObjectOperations;
+import tools.dynamia.commons.logger.LoggingService;
+import tools.dynamia.domain.EntityReference;
import tools.dynamia.domain.ValidationError;
+import tools.dynamia.domain.util.DomainUtils;
+import tools.dynamia.integration.Containers;
import tools.dynamia.navigation.NavigationTree;
import tools.dynamia.viewers.ViewDescriptor;
+import java.util.HashMap;
import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
/**
* REST controller for exposing application metadata, navigation, entities, and actions.
@@ -40,6 +43,9 @@
@Tag(name = "DynamiaApplicationMetadata")
@DependsOn({"applicationInfo"})
public class ApplicationMetadataController {
+
+ private final static LoggingService logger = LoggingService.get(ApplicationMetadataController.class);
+
/**
* Base path for all metadata endpoints.
*/
@@ -48,6 +54,7 @@ public class ApplicationMetadataController {
* Loader for application metadata.
*/
private final ApplicationMetadataLoader metadataLoader;
+ private final EntityMetadata unknowEntity;
/**
* Cached application metadata.
*/
@@ -63,7 +70,7 @@ public class ApplicationMetadataController {
/**
* Cache for individual entity metadata.
*/
- private SimpleCache entitiesCache = new SimpleCache<>();
+
/**
* Constructs a new {@code ApplicationMetadataController} with the given metadata loader.
@@ -72,6 +79,20 @@ public class ApplicationMetadataController {
*/
public ApplicationMetadataController(ApplicationMetadataLoader metadataLoader) {
this.metadataLoader = metadataLoader;
+ this.unknowEntity = new EntityMetadata();
+ unknowEntity.setClassName("unknown");
+ unknowEntity.setName("Unknown Entity");
+ unknowEntity.setActions(List.of());
+ unknowEntity.setDescriptors(List.of());
+ unknowEntity.setActionsEndpoint("");
+ unknowEntity.setEndpoint("");
+ }
+
+
+ private void initMetadata() {
+ if (entities == null) {
+ entities = metadataLoader.loadEntities();
+ }
}
/**
@@ -134,8 +155,12 @@ public ActionExecutionResponse executeGlobalAction(@PathVariable("action") Strin
private static ActionExecutionResponse executeAction(String action, ActionExecutionRequest request, ActionMetadata actionMetadata) {
if (actionMetadata != null) {
try {
- var actionInstance = actionMetadata.getAction();
+ RemoteAction actionInstance = null;
+ if (actionMetadata.getAction() != null) {
+ actionInstance = Containers.get().findObject(actionMetadata.getAction().getClass());
+ }
if (ActionRestrictions.allowAccess(actionInstance)) {
+ logger.info("Executing action " + action);
return Actions.execute(actionInstance, request);
} else {
return new ActionExecutionResponse("Action " + action + " not allowed", HttpStatus.FORBIDDEN.getReasonPhrase(), 403);
@@ -159,12 +184,11 @@ private static ActionExecutionResponse executeAction(String action, ActionExecut
*/
@GetMapping(value = "/entities", produces = "application/json")
public ApplicationMetadataEntities getEntities() {
- if (entities == null) {
- entities = metadataLoader.loadEntities();
- }
+ initMetadata();
return entities;
}
+
/**
* Returns metadata for a specific entity by its class name.
*
@@ -173,17 +197,37 @@ public ApplicationMetadataEntities getEntities() {
*/
@GetMapping(value = "/entities/{className}", produces = "application/json")
public EntityMetadata getEntityMetadata(@PathVariable String className) {
- if (entities == null) {
- entities = metadataLoader.loadEntities();
+ initMetadata();
+ var result = entities.getEntityMetadata(className);
+ if (result == null) {
+ result = tryToFindEntityClass(className);
}
- return entitiesCache.getOrLoad(className, c -> {
- var metadata = entities.getEntityMetadata(className);
- if (metadata != null) {
- return metadataLoader.loadEntityMetadata(metadata.getEntityClass()); //force reload cache
- }
- return null;
- });
+ if (result == null) {
+ result = unknowEntity;
+ }
+
+ return result;
+
+ }
+
+ private EntityMetadata tryToFindEntityClass(String className) {
+ AtomicReference found = new AtomicReference<>();
+ if (ObjectOperations.isValidClassName(className)) {
+
+ getNavigation().forEachNode(node -> {
+ if (node.getType().equals("CrudPage") && node.getFile().equals(className)) {
+ var clazz = ObjectOperations.findClass(className);
+ if (clazz != null) {
+ var entityMetadata = metadataLoader.loadEntityMetadata(clazz);
+ entities.getEntities().add(entityMetadata);
+ found.set(entityMetadata);
+ }
+ }
+ });
+
+ }
+ return found.get();
}
/**
@@ -193,8 +237,8 @@ public EntityMetadata getEntityMetadata(@PathVariable String className) {
* @return the list of {@link ViewDescriptor} objects
*/
@GetMapping(value = "/entities/{className}/views", produces = "application/json")
- public List executeEntityAction(@PathVariable String className) {
- var entityMetadata = getEntities().getEntityMetadata(className);
+ public List getEntityViewDescriptors(@PathVariable String className) {
+ var entityMetadata = getEntityMetadata(className);
if (entityMetadata != null) {
return entityMetadata.getDescriptors().stream().map(ViewDescriptorMetadata::getDescriptor).toList();
}
@@ -230,7 +274,7 @@ public ViewDescriptor getEntityViewDescriptor(@PathVariable String className, @P
*/
@PostMapping(value = "/entities/{className}/action/{action}", produces = "application/json", consumes = "application/json")
public ActionExecutionResponse executeEntityAction(@PathVariable String className, @PathVariable String action, @RequestBody ActionExecutionRequest request) {
- var entityMetadata = getEntities().getEntityMetadata(className);
+ var entityMetadata = getEntityMetadata(className);
if (entityMetadata != null) {
var actionMetadata = entityMetadata.getActions().stream().filter(a -> a.getId().equals(action)).findFirst().orElse(null);
return executeAction(action, request, actionMetadata);
@@ -238,4 +282,21 @@ public ActionExecutionResponse executeEntityAction(@PathVariable String classNam
return new ActionExecutionResponse("Entity " + className + " not found", HttpStatus.NOT_FOUND.getReasonPhrase(), 404);
}
+ @GetMapping(value = "/entities/ref/{alias}/{id}", produces = "application/json")
+ public EntityReference getEntityReference(@PathVariable String alias, @PathVariable String id) {
+ return DomainUtils.getEntityReference(alias, id);
+ }
+
+ @GetMapping(value = "/entities/ref/{alias}/search", produces = "application/json")
+ public List findEntityReferences(@PathVariable String alias, @RequestParam("q") String query) {
+ var repo = DomainUtils.getEntityReferenceRepositoryByAlias(alias);
+ var params = new HashMap();
+
+
+ if (repo != null) {
+ return repo.find(query, params);
+ }
+ return List.of();
+ }
+
}
diff --git a/platform/app/src/main/java/tools/dynamia/app/metadata/ActionMetadata.java b/platform/app/src/main/java/tools/dynamia/app/metadata/ActionMetadata.java
index 7780d9ee..fb976e1d 100644
--- a/platform/app/src/main/java/tools/dynamia/app/metadata/ActionMetadata.java
+++ b/platform/app/src/main/java/tools/dynamia/app/metadata/ActionMetadata.java
@@ -4,9 +4,11 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.dynamia.actions.Action;
import tools.dynamia.actions.ClassAction;
+import tools.dynamia.actions.RemoteAction;
import tools.dynamia.app.controllers.ApplicationMetadataController;
import tools.dynamia.commons.Streams;
import tools.dynamia.crud.CrudAction;
+import tools.dynamia.crud.CrudRemoteAction;
import java.util.List;
@@ -55,7 +57,10 @@ public class ActionMetadata extends BasicMetadata {
* The underlying {@link Action} instance. This field is ignored during JSON serialization.
*/
@JsonIgnore
- private Action action;
+ private RemoteAction action;
+
+ private String type;
+ private String className;
/**
* Default constructor for serialization and manual instantiation.
@@ -71,13 +76,18 @@ public ActionMetadata() {
*
* @param action the {@link Action} instance to extract metadata from
*/
- public ActionMetadata(Action action) {
+ public ActionMetadata(RemoteAction action) {
setId(action.getId());
setName(action.getLocalizedName());
setDescription(action.getLocalizedDescription());
setIcon(action.getImage());
setEndpoint(ApplicationMetadataController.PATH + "/actions/execute/" + getId());
-
+ setClassName(action.getClass().getSimpleName());
+ setType(switch (action) {
+ case CrudRemoteAction crudAction -> "CrudAction";
+ case ClassAction classAction -> "ClassAction";
+ default -> "Action";
+ });
var actionRenderer = action.getRenderer();
this.renderer = actionRenderer != null ? actionRenderer.getClass().getName() : null;
this.group = action.getGroup() != null ? action.getGroup().getName() : null;
@@ -89,6 +99,8 @@ public ActionMetadata(Action action) {
if (action instanceof ClassAction classAction) {
this.applicableClasses = Streams.mapAndCollect(classAction.getApplicableClasses(), a -> a.targetClass() == null ? "all" : a.targetClass().getName());
}
+
+ this.action = action;
}
/**
@@ -170,7 +182,23 @@ public void setApplicableStates(List applicableStates) {
*
* @return the {@link Action} instance
*/
- public Action getAction() {
+ public RemoteAction getAction() {
return action;
}
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public String getClassName() {
+ return className;
+ }
+
+ public void setClassName(String className) {
+ this.className = className;
+ }
}
diff --git a/platform/app/src/main/java/tools/dynamia/app/metadata/ApplicationMetadataLoader.java b/platform/app/src/main/java/tools/dynamia/app/metadata/ApplicationMetadataLoader.java
index 36103bbf..552698dc 100644
--- a/platform/app/src/main/java/tools/dynamia/app/metadata/ApplicationMetadataLoader.java
+++ b/platform/app/src/main/java/tools/dynamia/app/metadata/ApplicationMetadataLoader.java
@@ -3,14 +3,19 @@
import org.springframework.context.annotation.DependsOn;
import tools.dynamia.actions.ActionLoader;
import tools.dynamia.actions.ApplicationGlobalAction;
+import tools.dynamia.actions.ApplicationGlobalRemoteAction;
import tools.dynamia.app.ApplicationInfo;
import tools.dynamia.app.controllers.ApplicationMetadataController;
import tools.dynamia.commons.ApplicableClass;
import tools.dynamia.crud.CrudAction;
+import tools.dynamia.crud.CrudRemoteAction;
import tools.dynamia.integration.sterotypes.Service;
+import tools.dynamia.viewers.ViewDescriptor;
import tools.dynamia.viewers.ViewDescriptorFactory;
import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
/**
* Loader and factory for application metadata objects.
@@ -69,7 +74,7 @@ public ApplicationMetadataEntities loadEntities() {
viewDescriptorFactory.findDescriptorsByType("form")
.forEach(d -> {
var entityClass = d.getKey();
- var entity = new EntityMetadata(entityClass);
+ var entity = loadEntityMetadata(entityClass);
metadata.getEntities().add(entity);
});
return metadata;
@@ -83,7 +88,7 @@ public ApplicationMetadataEntities loadEntities() {
public ApplicationMetadataActions loadGlobalActions() {
ApplicationMetadataActions metadata = new ApplicationMetadataActions();
metadata.setActions(new ArrayList<>());
- ActionLoader actionLoader = new ActionLoader<>(ApplicationGlobalAction.class);
+ ActionLoader actionLoader = new ActionLoader<>(ApplicationGlobalRemoteAction.class);
actionLoader.load().forEach(action -> {
var actionMetadata = new ActionMetadata(action);
metadata.getActions().add(actionMetadata);
@@ -96,10 +101,16 @@ public ApplicationMetadataActions loadGlobalActions() {
public EntityMetadata loadEntityMetadata(Class entityClass) {
var entity = new EntityMetadata(entityClass);
- var descriptors = viewDescriptorFactory.findDescriptorByClass(entityClass);
+ Set descriptors = viewDescriptorFactory.findDescriptorByClass(entityClass);
+ if(descriptors==null || descriptors.isEmpty()) {
+ descriptors = new HashSet<>();
+ descriptors.add(viewDescriptorFactory.getDescriptor(entityClass, "form"));
+ descriptors.add(viewDescriptorFactory.getDescriptor(entityClass, "table"));
+ descriptors.add(viewDescriptorFactory.getDescriptor(entityClass, "crud"));
+ }
entity.setDescriptors(descriptors.stream().map(ViewDescriptorMetadata::new).toList());
- ActionLoader loader = new ActionLoader<>(CrudAction.class);
+ ActionLoader loader = new ActionLoader<>(CrudRemoteAction.class);
entity.setActions(loader
.load(action -> isApplicable(entityClass, action))
.stream().map(a -> {
@@ -113,7 +124,7 @@ public EntityMetadata loadEntityMetadata(Class entityClass) {
return entity;
}
- private boolean isApplicable(final Class targetClass, CrudAction crudAction) {
+ private boolean isApplicable(final Class targetClass, CrudRemoteAction crudAction) {
return ApplicableClass.isApplicable(targetClass, crudAction.getApplicableClasses(), true);
}
diff --git a/platform/core/actions/pom.xml b/platform/core/actions/pom.xml
index 7c3a8683..13e590b1 100644
--- a/platform/core/actions/pom.xml
+++ b/platform/core/actions/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
@@ -65,12 +65,12 @@
tools.dynamia
tools.dynamia.integration
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.commons
- 26.3.1
+ 26.4.0
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractAction.java
index 124b5616..1eb5a5bd 100644
--- a/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractAction.java
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractAction.java
@@ -65,9 +65,7 @@
* }
*
*
- * Actions can be triggered from UI components, toolbars, menus, or programmatically, and can respond to external requests
- * such as REST API calls using {@link #execute(ActionExecutionRequest)}.
- *
+ * Actions can be triggered from UI components, toolbars, menus, or programmatically.
*
* Localization: To support multiple languages, place Messages.properties files in the same package as your action class.
* For example: Messages_es.properties, Messages_fr.properties, etc.
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractClassAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractClassAction.java
index fc213382..b7021704 100644
--- a/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractClassAction.java
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractClassAction.java
@@ -19,6 +19,6 @@
/**
* Extend {@link AbstractAction} and implement {@link ClassAction}
*/
-public abstract class AbstractClassAction extends AbstractAction implements ClassAction {
+public abstract class AbstractClassAction extends AbstractLocalAction implements ClassAction {
}
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractLocalAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractLocalAction.java
new file mode 100644
index 00000000..4de23474
--- /dev/null
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractLocalAction.java
@@ -0,0 +1,7 @@
+package tools.dynamia.actions;
+
+/**
+ * Abstract base class for LocalAction implementations.
+ */
+public abstract class AbstractLocalAction extends AbstractAction implements LocalAction {
+}
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractRemoteAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractRemoteAction.java
new file mode 100644
index 00000000..4f04fb88
--- /dev/null
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/AbstractRemoteAction.java
@@ -0,0 +1,7 @@
+package tools.dynamia.actions;
+
+/**
+ * Abstract base class for RemoteAction implementations.
+ */
+public abstract class AbstractRemoteAction extends AbstractAction implements RemoteAction {
+}
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/Action.java b/platform/core/actions/src/main/java/tools/dynamia/actions/Action.java
index 42d006f9..99d12f49 100644
--- a/platform/core/actions/src/main/java/tools/dynamia/actions/Action.java
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/Action.java
@@ -58,8 +58,7 @@
* }
*
*
- * Actions can be triggered from UI components, toolbars, menus, or programmatically, and can respond to external requests
- * such as REST API calls using {@link #execute(ActionExecutionRequest)}.
+ * Actions can be triggered from UI components, toolbars, menus, or programmatically.
*
*
* @author Mario A. Serrano Leones
@@ -223,29 +222,7 @@ default int getKeyCode() {
*/
Action getParent();
- /**
- * Called when the action is performed server-side (e.g., from UI or logic).
- *
- * Implement this method to define the behavior when the action is triggered by the user or system.
- *
- *
- * @param evt the action event
- */
- void actionPerformed(ActionEvent evt);
- /**
- * Executes this action using an {@link ActionExecutionRequest} instead of an {@link ActionEvent}.
- *
- * Implement this method to process the action result for external systems (e.g., REST API, automation).
- * The default implementation returns a response with success=false.
- *
- *
- * @param request the execution request
- * @return the execution response
- */
- default ActionExecutionResponse execute(ActionExecutionRequest request) {
- return new ActionExecutionResponse(false);
- }
/**
* Converts this action into an {@link ActionReference} for lightweight representation.
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/ActionPlaceholder.java b/platform/core/actions/src/main/java/tools/dynamia/actions/ActionPlaceholder.java
index 09ae5f6f..08e6f5f7 100644
--- a/platform/core/actions/src/main/java/tools/dynamia/actions/ActionPlaceholder.java
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/ActionPlaceholder.java
@@ -16,16 +16,5 @@
* @author Mario A. Serrano Leones
*/
public class ActionPlaceholder extends AbstractAction {
- /**
- * Does nothing when the action is performed.
- *
- * This method is intentionally left empty to indicate that no operation should occur.
- *
- *
- * @param evt the action event (ignored)
- */
- @Override
- public void actionPerformed(ActionEvent evt) {
- // do nothing
- }
+
}
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/Actions.java b/platform/core/actions/src/main/java/tools/dynamia/actions/Actions.java
index 5398ae3f..7b1450b9 100644
--- a/platform/core/actions/src/main/java/tools/dynamia/actions/Actions.java
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/Actions.java
@@ -190,7 +190,7 @@ public static Object getActionComponent(Action action) {
* @param request the execution request
* @return the execution response
*/
- public static ActionExecutionResponse execute(Action action, ActionExecutionRequest request) {
+ public static ActionExecutionResponse execute(RemoteAction action, ActionExecutionRequest request) {
if (action instanceof ActionSelfFilter filter) {
filter.beforeActionExecution(request);
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/ApplicationGlobalAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/ApplicationGlobalAction.java
index 69e37a28..92c35706 100644
--- a/platform/core/actions/src/main/java/tools/dynamia/actions/ApplicationGlobalAction.java
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/ApplicationGlobalAction.java
@@ -29,7 +29,7 @@
* for implementing custom global actions.
*
*/
-public abstract class ApplicationGlobalAction extends AbstractAction {
+public abstract class ApplicationGlobalAction extends AbstractLocalAction {
// No additional methods or fields. Subclasses should implement specific global actions.
}
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/ApplicationGlobalRemoteAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/ApplicationGlobalRemoteAction.java
new file mode 100644
index 00000000..ef60f3c6
--- /dev/null
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/ApplicationGlobalRemoteAction.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package tools.dynamia.actions;
+
+/**
+ * Represents a global remote action that is available throughout the entire application.
+ *
+ * Subclasses of {@code ApplicationGlobalRemoteAction} define actions that are not limited to a specific context or module,
+ * but can be accessed and executed from anywhere in the application. These actions are typically used for
+ * application-wide operations such as global shortcuts, settings, or utilities.
+ *
+ *
+ * This abstract class extends {@link AbstractRemoteAction}, inheriting its properties and behavior, and serves as a base
+ * for implementing custom global actions.
+ *
+ */
+public abstract class ApplicationGlobalRemoteAction extends AbstractRemoteAction {
+
+ // No additional methods or fields. Subclasses should implement specific global actions.
+}
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/DefaultActionRunner.java b/platform/core/actions/src/main/java/tools/dynamia/actions/DefaultActionRunner.java
index 145e1034..0579b9f9 100644
--- a/platform/core/actions/src/main/java/tools/dynamia/actions/DefaultActionRunner.java
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/DefaultActionRunner.java
@@ -7,6 +7,8 @@ public class DefaultActionRunner implements ActionRunner {
@Override
public void run(Action action, ActionEvent evt) {
- action.actionPerformed(evt);
+ if(action instanceof LocalAction localAction) {
+ localAction.actionPerformed(evt);
+ }
}
}
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/FastAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/FastAction.java
index 32ca51f1..81ce1a23 100644
--- a/platform/core/actions/src/main/java/tools/dynamia/actions/FastAction.java
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/FastAction.java
@@ -44,7 +44,7 @@
*
* @author Mario A. Serrano Leones
*/
-public class FastAction extends AbstractAction {
+public class FastAction extends AbstractLocalAction {
/**
* Functional interface to handle action performed events.
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/LocalAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/LocalAction.java
new file mode 100644
index 00000000..a956c7f4
--- /dev/null
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/LocalAction.java
@@ -0,0 +1,18 @@
+package tools.dynamia.actions;
+
+/**
+ * A LocalAction is an Action that is executed server-side, typically triggered from the user interface or application logic.
+ *
+ */
+public interface LocalAction extends Action {
+
+ /**
+ * Called when the action is performed server-side (e.g., from UI or logic).
+ *
+ * Implement this method to define the behavior when the action is triggered by the user or system.
+ *
+ *
+ * @param evt the action event
+ */
+ void actionPerformed(ActionEvent evt);
+}
diff --git a/platform/core/actions/src/main/java/tools/dynamia/actions/RemoteAction.java b/platform/core/actions/src/main/java/tools/dynamia/actions/RemoteAction.java
new file mode 100644
index 00000000..85582f6a
--- /dev/null
+++ b/platform/core/actions/src/main/java/tools/dynamia/actions/RemoteAction.java
@@ -0,0 +1,25 @@
+package tools.dynamia.actions;
+
+/**
+ * A RemoteAction is an Action that can be executed remotely, such as through a REST API or automation framework.
+ *
+ * This interface extends the basic Action interface and adds a method for executing the action using an ActionExecutionRequest, which allows for processing action results in external systems.
+ * Implement this interface for actions that need to be triggered from outside the application, such as via API calls or automated tasks.
+ *
+ */
+public interface RemoteAction extends Action {
+
+
+
+ /**
+ * Executes this action using an {@link ActionExecutionRequest} instead of an {@link ActionEvent}.
+ *
+ * Implement this method to process the action result for external systems (e.g., REST API, automation).
+ * The default implementation returns a response with success=false.
+ *
+ *
+ * @param request the execution request
+ * @return the execution response
+ */
+ ActionExecutionResponse execute(ActionExecutionRequest request);
+}
diff --git a/platform/core/commons/pom.xml b/platform/core/commons/pom.xml
index 17d224f8..dd6455de 100644
--- a/platform/core/commons/pom.xml
+++ b/platform/core/commons/pom.xml
@@ -25,7 +25,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
DynamiaTools - Commons
diff --git a/platform/core/commons/src/main/java/tools/dynamia/commons/Mappable.java b/platform/core/commons/src/main/java/tools/dynamia/commons/Mappable.java
index 184dfe15..46d74a20 100644
--- a/platform/core/commons/src/main/java/tools/dynamia/commons/Mappable.java
+++ b/platform/core/commons/src/main/java/tools/dynamia/commons/Mappable.java
@@ -46,12 +46,12 @@ public interface Mappable {
/**
* Converts the current object to a {@link Map} of property names and values.
*
- * Uses {@link ObjectOperations#getValuesMaps(Object)} to extract all standard properties.
+ * Uses {@link ObjectOperations#getNonNullValuesMaps(Object)} to extract all standard properties but excluding non-null values.
*
* @return a map containing property names as keys and their corresponding values
*/
default Map toMap() {
- return ObjectOperations.getValuesMaps(this);
+ return ObjectOperations.getNonNullValuesMaps(this);
}
}
diff --git a/platform/core/commons/src/main/java/tools/dynamia/commons/ObjectOperations.java b/platform/core/commons/src/main/java/tools/dynamia/commons/ObjectOperations.java
index 47f93389..52475cd1 100644
--- a/platform/core/commons/src/main/java/tools/dynamia/commons/ObjectOperations.java
+++ b/platform/core/commons/src/main/java/tools/dynamia/commons/ObjectOperations.java
@@ -16,7 +16,6 @@
*/
package tools.dynamia.commons;
-import org.springframework.beans.BeanUtils;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
import tools.dynamia.commons.logger.LoggingService;
@@ -859,6 +858,24 @@ public static Map getValuesMaps(Object bean) {
return getValuesMaps("", bean, null);
}
+ /**
+ * Load all bean standard properties into map but only with non null values
+ *
+ * @param bean objet to scan
+ * @return values
+ */
+ public static Map getNonNullValuesMaps(Object bean) {
+
+ var finalMap = new HashMap();
+ getValuesMaps("", bean, null)
+ .forEach((k, v) -> {
+ if (v != null) {
+ finalMap.put(k, v);
+ }
+ });
+ return finalMap;
+ }
+
/**
* Load all beans standard properties into a map.
*
diff --git a/platform/core/crud/pom.xml b/platform/core/crud/pom.xml
index 285cfd9f..1af5c40f 100644
--- a/platform/core/crud/pom.xml
+++ b/platform/core/crud/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
@@ -62,23 +62,23 @@
tools.dynamia
tools.dynamia.actions
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.viewers
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.navigation
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.domain.jpa
- 26.3.1
+ 26.4.0
test
diff --git a/platform/core/crud/src/main/java/tools/dynamia/crud/AbstractCrudRemoteAction.java b/platform/core/crud/src/main/java/tools/dynamia/crud/AbstractCrudRemoteAction.java
new file mode 100644
index 00000000..a072d213
--- /dev/null
+++ b/platform/core/crud/src/main/java/tools/dynamia/crud/AbstractCrudRemoteAction.java
@@ -0,0 +1,33 @@
+package tools.dynamia.crud;
+
+import tools.dynamia.actions.AbstractAction;
+import tools.dynamia.commons.ApplicableClass;
+
+public abstract class AbstractCrudRemoteAction extends AbstractAction implements CrudRemoteAction {
+
+ private ApplicableClass[] applicableClasses = ApplicableClass.ALL;
+ private CrudState[] applicableStates = CrudState.get(CrudState.READ);
+
+ @Override
+ public CrudState[] getApplicableStates() {
+ return applicableStates;
+ }
+
+ public void setApplicableStates(CrudState[] applicableStates) {
+ this.applicableStates = applicableStates;
+ }
+
+ @Override
+ public ApplicableClass[] getApplicableClasses() {
+ return applicableClasses;
+ }
+
+ public void setApplicableClasses(ApplicableClass[] applicableClasses) {
+ this.applicableClasses = applicableClasses;
+ }
+
+ public void setApplicableClass(Class clazz) {
+ setApplicableClasses(ApplicableClass.get(clazz));
+ }
+
+}
diff --git a/platform/core/crud/src/main/java/tools/dynamia/crud/CrudAction.java b/platform/core/crud/src/main/java/tools/dynamia/crud/CrudAction.java
index 44876e34..b6ef0b18 100644
--- a/platform/core/crud/src/main/java/tools/dynamia/crud/CrudAction.java
+++ b/platform/core/crud/src/main/java/tools/dynamia/crud/CrudAction.java
@@ -17,6 +17,7 @@
package tools.dynamia.crud;
import tools.dynamia.actions.ClassAction;
+import tools.dynamia.actions.LocalAction;
/**
* The Interface CrudAction. Represents actions that can be performed in CRUD operations.
@@ -29,25 +30,25 @@
*
*
* public class DeleteUserAction implements CrudAction {
- *
- * public CrudState[] getApplicableStates() {
- * return new CrudState[]{CrudState.READ};
- * }
- *
- * public boolean isMenuSupported() {
- * return true;
- * }
- *
- * public void actionPerformed(ActionEvent evt) {
- * User user = (User) evt.getData();
- * userService.delete(user);
- * }
+ *
+ * public CrudState[] getApplicableStates() {
+ * return new CrudState[]{CrudState.READ};
+ * }
+ *
+ * public boolean isMenuSupported() {
+ * return true;
+ * }
+ *
+ * public void actionPerformed(ActionEvent evt) {
+ * User user = (User) evt.getData();
+ * userService.delete(user);
+ * }
* }
*
*
* @author Mario A. Serrano Leones
*/
-public interface CrudAction extends ClassAction {
+public interface CrudAction extends LocalAction, ClassAction {
/**
* Gets the states where this action is applicable.
diff --git a/platform/core/crud/src/main/java/tools/dynamia/crud/CrudRemoteAction.java b/platform/core/crud/src/main/java/tools/dynamia/crud/CrudRemoteAction.java
new file mode 100644
index 00000000..b826b8f5
--- /dev/null
+++ b/platform/core/crud/src/main/java/tools/dynamia/crud/CrudRemoteAction.java
@@ -0,0 +1,15 @@
+package tools.dynamia.crud;
+
+import tools.dynamia.actions.ClassAction;
+import tools.dynamia.actions.RemoteAction;
+
+
+public interface CrudRemoteAction extends RemoteAction, ClassAction {
+
+ /**
+ * Gets the states where this action is applicable.
+ *
+ * @return the array of applicable CRUD states
+ */
+ CrudState[] getApplicableStates();
+}
diff --git a/platform/core/crud/src/main/java/tools/dynamia/crud/actions/DeleteAction.java b/platform/core/crud/src/main/java/tools/dynamia/crud/actions/DeleteAction.java
index 422aaa52..16ed8d60 100644
--- a/platform/core/crud/src/main/java/tools/dynamia/crud/actions/DeleteAction.java
+++ b/platform/core/crud/src/main/java/tools/dynamia/crud/actions/DeleteAction.java
@@ -16,17 +16,15 @@
*/
package tools.dynamia.crud.actions;
-import tools.dynamia.actions.ActionExecutionRequest;
-import tools.dynamia.actions.ActionExecutionResponse;
import tools.dynamia.actions.ActionGroup;
import tools.dynamia.actions.InstallAction;
import tools.dynamia.commons.Messages;
import tools.dynamia.crud.AbstractCrudAction;
import tools.dynamia.crud.CrudActionEvent;
import tools.dynamia.crud.CrudState;
-import tools.dynamia.domain.util.DomainUtils;
/**
+ * Local action to delete selected entity
* @author Mario A. Serrano Leones
*/
@InstallAction
@@ -51,13 +49,4 @@ public void actionPerformed(CrudActionEvent evt) {
evt.getController().delete(evt.getData());
}
- @Override
- public ActionExecutionResponse execute(ActionExecutionRequest request) {
- Object result = null;
- if (DomainUtils.isEntity(request.getData())) {
- crudService().delete(request.getData());
- result = request.getData();
- }
- return new ActionExecutionResponse(result, "OK", 200);
- }
}
diff --git a/platform/core/crud/src/main/java/tools/dynamia/crud/actions/SaveAction.java b/platform/core/crud/src/main/java/tools/dynamia/crud/actions/SaveAction.java
index 030d120d..cc5fd878 100644
--- a/platform/core/crud/src/main/java/tools/dynamia/crud/actions/SaveAction.java
+++ b/platform/core/crud/src/main/java/tools/dynamia/crud/actions/SaveAction.java
@@ -16,8 +16,6 @@
*/
package tools.dynamia.crud.actions;
-import tools.dynamia.actions.ActionExecutionRequest;
-import tools.dynamia.actions.ActionExecutionResponse;
import tools.dynamia.actions.ActionGroup;
import tools.dynamia.actions.InstallAction;
import tools.dynamia.commons.Callback;
@@ -27,9 +25,9 @@
import tools.dynamia.crud.CrudState;
import tools.dynamia.crud.CrudViewComponent;
import tools.dynamia.domain.ValidationError;
-import tools.dynamia.domain.util.DomainUtils;
/**
+ * Local action to save an entity
* @author Mario A. Serrano Leones
*/
@InstallAction
@@ -79,12 +77,4 @@ protected void afterSave(Object entity, CrudViewComponent crud) {
}
- @Override
- public ActionExecutionResponse execute(ActionExecutionRequest request) {
- Object result = null;
- if (DomainUtils.isEntity(request.getData())) {
- result = crudService().save(request.getData());
- }
- return new ActionExecutionResponse(result, "OK", 200);
- }
}
diff --git a/platform/core/crud/src/main/java/tools/dynamia/crud/cfg/AbstractConfigPageAction.java b/platform/core/crud/src/main/java/tools/dynamia/crud/cfg/AbstractConfigPageAction.java
index 276cf83e..b3fb010a 100644
--- a/platform/core/crud/src/main/java/tools/dynamia/crud/cfg/AbstractConfigPageAction.java
+++ b/platform/core/crud/src/main/java/tools/dynamia/crud/cfg/AbstractConfigPageAction.java
@@ -16,9 +16,9 @@
*/
package tools.dynamia.crud.cfg;
-import tools.dynamia.actions.AbstractAction;
+import tools.dynamia.actions.AbstractLocalAction;
-public abstract class AbstractConfigPageAction extends AbstractAction {
+public abstract class AbstractConfigPageAction extends AbstractLocalAction {
private String applicableConfig;
diff --git a/platform/core/domain-jpa/pom.xml b/platform/core/domain-jpa/pom.xml
index 05b8b2e5..1c088e30 100644
--- a/platform/core/domain-jpa/pom.xml
+++ b/platform/core/domain-jpa/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
@@ -65,7 +65,7 @@
tools.dynamia
tools.dynamia.domain
- 26.3.1
+ 26.4.0
diff --git a/platform/core/domain/pom.xml b/platform/core/domain/pom.xml
index 798989ea..7dda4c97 100644
--- a/platform/core/domain/pom.xml
+++ b/platform/core/domain/pom.xml
@@ -26,7 +26,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
DynamiaTools - Domain
diff --git a/platform/core/domain/src/main/java/tools/dynamia/domain/DefaultEntityReferenceRepository.java b/platform/core/domain/src/main/java/tools/dynamia/domain/DefaultEntityReferenceRepository.java
index cccec6ec..f2caf236 100644
--- a/platform/core/domain/src/main/java/tools/dynamia/domain/DefaultEntityReferenceRepository.java
+++ b/platform/core/domain/src/main/java/tools/dynamia/domain/DefaultEntityReferenceRepository.java
@@ -455,7 +455,7 @@ protected EntityReference createEntityReference(ID id, Object entity) {
ref.getAttributes().putAll(((Mappable) entity).toMap());
} else {
//noinspection unchecked
- ref.getAttributes().putAll(ObjectOperations.getValuesMaps(entity));
+ ref.getAttributes().putAll(ObjectOperations.getNonNullValuesMaps(entity));
}
}
//noinspection unchecked
diff --git a/platform/core/domain/src/main/java/tools/dynamia/domain/EntityReference.java b/platform/core/domain/src/main/java/tools/dynamia/domain/EntityReference.java
index 07088d1b..7bb57325 100644
--- a/platform/core/domain/src/main/java/tools/dynamia/domain/EntityReference.java
+++ b/platform/core/domain/src/main/java/tools/dynamia/domain/EntityReference.java
@@ -16,6 +16,10 @@
*/
package tools.dynamia.domain;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
@@ -89,6 +93,8 @@
* @see tools.dynamia.domain.EntityReferenceRepository
* @see tools.dynamia.domain.DefaultEntityReferenceRepository
*/
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({ "id", "name", "className", "description", "attributes" })
public class EntityReference implements Serializable {
/**
@@ -119,6 +125,7 @@ public class EntityReference implements Serializable {
/**
* Additional custom attributes for the referenced entity
*/
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
private final Map attributes = new HashMap<>();
/**
diff --git a/platform/core/domain/src/main/java/tools/dynamia/domain/InstallValidator.java b/platform/core/domain/src/main/java/tools/dynamia/domain/InstallValidator.java
new file mode 100644
index 00000000..d5ef7623
--- /dev/null
+++ b/platform/core/domain/src/main/java/tools/dynamia/domain/InstallValidator.java
@@ -0,0 +1,32 @@
+package tools.dynamia.domain;
+
+import org.springframework.stereotype.Component;
+
+import java.lang.annotation.*;
+
+/**
+ * Annotation to mark a class as an installable {@link Validator} in the application context.
+ *
+ * Classes annotated with {@code InstallValidator} are automatically registered as Spring components
+ * with prototype scope, allowing them to be instantiated and managed by the framework as validators.
+ * This annotation is typically used to facilitate the dynamic discovery and installation of validators
+ * in modular or plugin-based systems.
+ *
+ *
+ * Example usage:
+ *
+ * @InstallValidator
+ * public class MyCustomAction implements Validator {
+ * // Implementation details
+ * }
+ *
+ *
+ *
+ * @author Mario A. Serrano Leones
+ */
+@Target(ElementType.TYPE)
+@Component
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+public @interface InstallValidator {
+}
diff --git a/platform/core/domain/src/main/java/tools/dynamia/domain/Validator.java b/platform/core/domain/src/main/java/tools/dynamia/domain/Validator.java
index 6aadf50a..d72c4da9 100644
--- a/platform/core/domain/src/main/java/tools/dynamia/domain/Validator.java
+++ b/platform/core/domain/src/main/java/tools/dynamia/domain/Validator.java
@@ -156,7 +156,7 @@
* @see ValidationError
* @see ValidatorUtil
* @see tools.dynamia.domain.services.ValidatorService
- * @see tools.dynamia.integration.sterotypes.Provider
+ * @see tools.dynamia.domain.InstallValidator
*/
public interface Validator {
diff --git a/platform/core/domain/src/main/java/tools/dynamia/domain/util/DomainUtils.java b/platform/core/domain/src/main/java/tools/dynamia/domain/util/DomainUtils.java
index 05c6ded5..d332ad4e 100644
--- a/platform/core/domain/src/main/java/tools/dynamia/domain/util/DomainUtils.java
+++ b/platform/core/domain/src/main/java/tools/dynamia/domain/util/DomainUtils.java
@@ -17,12 +17,9 @@
package tools.dynamia.domain.util;
import tools.dynamia.commons.BeanSorter;
-import tools.dynamia.commons.ObjectOperations;
import tools.dynamia.commons.Identifiable;
-import tools.dynamia.domain.CurrencyExchangeProvider;
-import tools.dynamia.domain.EntityReferenceRepository;
-import tools.dynamia.domain.EntityUtilsProvider;
-import tools.dynamia.domain.ValidationError;
+import tools.dynamia.commons.ObjectOperations;
+import tools.dynamia.domain.*;
import tools.dynamia.domain.query.Parameter;
import tools.dynamia.domain.services.CrudService;
import tools.dynamia.domain.services.GraphCrudService;
@@ -63,9 +60,8 @@ public abstract class DomainUtils {
* @param original the original string to clean
* @param onlyNumbers if true, keeps only digits and hyphens; if false, keeps letters, digits, and whitespace
* @return the cleaned string in uppercase
- *
* @see #cleanString(String)
- *
+ *
* Example:
*
{@code
* String result = cleanString("Hello-123!", false); // Returns "HELLO 123"
@@ -95,9 +91,8 @@ public static String cleanString(String original, boolean onlyNumbers) {
*
* @param original the original string to clean
* @return the cleaned string in uppercase, containing only letters, digits, and whitespace
- *
* @see #cleanString(String, boolean)
- *
+ *
* Example:
*
{@code
* String result = cleanString("Hello, World! 123"); // Returns "HELLO WORLD 123"
@@ -114,7 +109,7 @@ public static String cleanString(String original) {
*
* @param src the source object to convert into a searchable pattern
* @return a string pattern with wildcards, suitable for SQL LIKE queries; returns "%" if src is null
- *
+ *
* Example:
*
{@code
* String pattern = buildSearcheableString("John Doe"); // Returns "%J%o%h%n%D%o%e%"
@@ -152,7 +147,7 @@ public static String buildSearcheableString(Object src) {
* @param queryText the original query string
* @param sorter the BeanSorter containing column name and sort direction
* @return the query string with ORDER BY clause appended, or the original query if sorter is null
- *
+ *
* Example:
*
{@code
* BeanSorter sorter = new BeanSorter("name", true);
@@ -183,7 +178,7 @@ public static String configureSorter(String queryText, BeanSorter sorter) {
* @param n the BigDecimal number to round
* @param zeros the number of zeros in the rounding pattern (e.g., 2 for hundreds, 3 for thousands)
* @return the rounded BigDecimal value
- *
+ *
* Example:
*
{@code
* BigDecimal result = simpleRound(new BigDecimal("1567"), 2); // Returns 1600 (rounds to nearest 100)
@@ -213,7 +208,7 @@ public static BigDecimal simpleRound(BigDecimal n, int zeros) {
*
* @param n the BigDecimal number to round; can be null
* @return the rounded BigDecimal with no decimal places, or null if input is null
- *
+ *
* Example:
*
{@code
* BigDecimal result = simpleRound(new BigDecimal("1234.56")); // Returns 1234
@@ -242,7 +237,7 @@ public static BigDecimal simpleRound(BigDecimal n) {
* @param number the number to format
* @param numberReference the reference number that determines the total length
* @return the formatted string with leading zeros
- *
+ *
* Example:
*
{@code
* String result = formatNumberWithZeroes(5, 1000); // Returns "0005"
@@ -261,7 +256,7 @@ public static String formatNumberWithZeroes(long number, long numberReference) {
*
* @param values the BigDecimal values to sum
* @return the sum of all values; returns BigDecimal.ZERO if no values are provided
- *
+ *
* Example:
*
{@code
* BigDecimal result = sum(new BigDecimal("10"), new BigDecimal("20"), new BigDecimal("30"));
@@ -282,7 +277,7 @@ public static BigDecimal sum(BigDecimal... values) {
*
* @param values the BigDecimal values to subtract; the first value is the minuend
* @return the result after subtracting all values sequentially, or null if no values provided
- *
+ *
* Example:
*
{@code
* BigDecimal result = substract(new BigDecimal("100"), new BigDecimal("30"), new BigDecimal("20"));
@@ -310,12 +305,12 @@ public static BigDecimal substract(BigDecimal... values) {
* @param fieldName the name of the field to sum (must be a BigDecimal type)
* @return the sum of all field values across the collection
* @throws ValidationError if the field cannot be accessed or summed
- *
- * Example:
- * {@code
- * List products = Arrays.asList(product1, product2, product3);
- * BigDecimal totalPrice = sumField(products, Product.class, "price");
- * }
+ *
+ * Example:
+ *
{@code
+ * List products = Arrays.asList(product1, product2, product3);
+ * BigDecimal totalPrice = sumField(products, Product.class, "price");
+ * }
*/
public static BigDecimal sumField(Collection data, Class clazz, String fieldName) {
BigDecimal result = BigDecimal.ZERO;
@@ -338,9 +333,8 @@ public static BigDecimal sumField(Collection data, Class clazz, String fieldName
*
* @param entityClass the entity class to find the repository for
* @return the EntityReferenceRepository for the entity class, or null if not found
- *
* @see #getEntityReferenceRepository(String)
- *
+ *
* Example:
*
{@code
* EntityReferenceRepository repo = getEntityReferenceRepository(Customer.class);
@@ -356,7 +350,7 @@ public static EntityReferenceRepository getEntityReferenceRepository(Class entit
*
* @param className the fully qualified class name of the entity
* @return the EntityReferenceRepository for the entity, or null if not found or className is null
- *
+ *
* Example:
*
{@code
* EntityReferenceRepository repo = getEntityReferenceRepository("com.example.Customer");
@@ -381,7 +375,7 @@ public static EntityReferenceRepository getEntityReferenceRepository(String clas
*
* @param alias the alias to search for
* @return the EntityReferenceRepository matching the alias, or null if not found or alias is null
- *
+ *
* Example:
*
{@code
* EntityReferenceRepository repo = getEntityReferenceRepositoryByAlias("customer");
@@ -405,7 +399,7 @@ public static EntityReferenceRepository getEntityReferenceRepositoryByAlias(Stri
*
* @param number the BigDecimal number to check
* @return the original number if not null, or BigDecimal.ZERO if null
- *
+ *
* Example:
*
{@code
* BigDecimal result = getZeroIfNull(null); // Returns BigDecimal.ZERO
@@ -423,9 +417,8 @@ public static BigDecimal getZeroIfNull(BigDecimal number) {
*
* @return the CrudService implementation
* @throws NotImplementationFoundException if no CrudService implementation is found
- *
* @see #lookupCrudService(Class)
- *
+ *
* Example:
*
{@code
* CrudService service = lookupCrudService();
@@ -448,9 +441,8 @@ public static CrudService lookupCrudService() {
* @param crudClass the class of the CrudService implementation to find
* @return the CrudService implementation of the specified type
* @throws NotImplementationFoundException if no implementation of the specified type is found
- *
* @see #lookupCrudService()
- *
+ *
* Example:
*
{@code
* GraphCrudService graphService = lookupCrudService(GraphCrudService.class);
@@ -470,9 +462,8 @@ public static T lookupCrudService(Class crudClass) {
*
* @return the GraphCrudService implementation
* @throws NotImplementationFoundException if no GraphCrudService implementation is found
- *
* @see #lookupCrudService(Class)
- *
+ *
* Example:
*
{@code
* GraphCrudService service = lookupGraphCrudService();
@@ -494,7 +485,7 @@ private DomainUtils() {
* @param the entity type
* @param entity the entity object to extract the ID from
* @return the entity's ID as a Serializable, or null if entity is null or ID cannot be found
- *
+ *
* Example:
*
{@code
* Customer customer = new Customer();
@@ -526,9 +517,8 @@ public static Serializable findEntityId(E entity) {
*
* @param entity the object to check
* @return true if the object is an entity, false otherwise
- *
* @see #isEntity(Class)
- *
+ *
* Example:
*
{@code
* Customer customer = new Customer();
@@ -549,9 +539,8 @@ public static boolean isEntity(Object entity) {
*
* @param entityClass the class to check
* @return true if the class is an entity type, false otherwise
- *
* @see #isEntity(Object)
- *
+ *
* Example:
*
{@code
* boolean result = isEntity(Customer.class); // Returns true if Customer is a JPA entity
@@ -572,7 +561,7 @@ public static boolean isEntity(Class entityClass) {
*
* @param field the field to check for persistence capability
* @return true if the field can be persisted, false otherwise; defaults to true
- *
+ *
* Example:
*
{@code
* Field nameField = Customer.class.getDeclaredField("name");
@@ -592,7 +581,7 @@ public static boolean isPersitable(Field field) {
* Finds the current {@link EntityUtilsProvider} implementation and returns its default {@link Parameter} class.
*
* @return the default Parameter class, or null if no EntityUtilsProvider is found
- *
+ *
* Example:
*
{@code
* Class extends Parameter> paramClass = getDefaultParameterClass();
@@ -623,7 +612,7 @@ public static Class extends Parameter> getDefaultParameterClass() {
* @param target the source object to extract data from
* @param dtoClass the class of the DTO to create and populate
* @return a new instance of DTO class with all common properties set
- *
+ *
* Example:
*
{@code
* // Case 1: Entity to String
@@ -665,13 +654,13 @@ public static DTO autoDataTransferObject(Object target, Class dtoClas
*
* @param obj the object to validate
* @throws ValidationError if validation fails
- *
- * Example:
- * {@code
- * Customer customer = new Customer();
- * customer.setEmail("invalid-email");
- * validate(customer); // Throws ValidationError if email format is invalid
- * }
+ *
+ * Example:
+ *
{@code
+ * Customer customer = new Customer();
+ * customer.setEmail("invalid-email");
+ * validate(customer); // Throws ValidationError if email format is invalid
+ * }
*/
public static void validate(Object obj) {
ValidatorService service = Containers.get().findObject(ValidatorService.class);
@@ -688,9 +677,8 @@ public static void validate(Object obj) {
* @param alias the alias of the entity reference repository
* @param id the ID of the entity reference to load
* @return the name of the entity reference, or null if not found or parameters are null
- *
* @see #getEntityReferenceName(String, Serializable, String)
- *
+ *
* Example:
*
{@code
* String customerName = getEntityReferenceName("customer", 123L);
@@ -719,9 +707,8 @@ public static String getEntityReferenceName(String alias, Serializable id) {
* @param id the ID of the entity reference to load
* @param defaultValue the default value to return if name is not found or blank
* @return the name of the entity reference, or defaultValue if not found or blank
- *
* @see #getEntityReferenceName(String, Serializable)
- *
+ *
* Example:
*
{@code
* String customerName = getEntityReferenceName("customer", 999L, "Unknown Customer");
@@ -740,7 +727,7 @@ public static String getEntityReferenceName(String alias, Serializable id, Strin
* @param entity the entity containing the counter field
* @param counterName the name of the counter field to increment
* @return the new incremented counter value
- *
+ *
* Example:
*
{@code
* Invoice invoice = new Invoice();
@@ -759,7 +746,7 @@ public static long findNextCounterValue(Object entity, String counterName) {
* This provider is used for currency conversion operations.
*
* @return the CurrencyExchangeProvider instance, or null if no implementation is found
- *
+ *
* Example:
*
{@code
* CurrencyExchangeProvider provider = getCurrencyExchangeProvider();
@@ -808,4 +795,19 @@ public static boolean isInteger(String value) {
return false;
}
}
+
+ /**
+ * Return an entity reference using entity alias and id
+ *
+ * @param alias entity alias
+ * @param entityId id
+ * @return reference or null if not found
+ */
+ public static EntityReference getEntityReference(String alias, Serializable entityId) {
+ var repo = getEntityReferenceRepositoryByAlias(alias);
+ if (repo != null) {
+ return repo.load(entityId);
+ }
+ return null;
+ }
}
diff --git a/platform/core/integration/pom.xml b/platform/core/integration/pom.xml
index 61a7dfbb..a6173d9f 100644
--- a/platform/core/integration/pom.xml
+++ b/platform/core/integration/pom.xml
@@ -27,7 +27,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
@@ -67,7 +67,7 @@
tools.dynamia
tools.dynamia.commons
- 26.3.1
+ 26.4.0
provided
diff --git a/platform/core/io/pom.xml b/platform/core/io/pom.xml
index bc788334..464e6e78 100644
--- a/platform/core/io/pom.xml
+++ b/platform/core/io/pom.xml
@@ -28,7 +28,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
diff --git a/platform/core/navigation/pom.xml b/platform/core/navigation/pom.xml
index b1ddacf2..59438148 100644
--- a/platform/core/navigation/pom.xml
+++ b/platform/core/navigation/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
@@ -63,17 +63,17 @@
tools.dynamia
tools.dynamia.commons
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.integration
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.actions
- 26.3.1
+ 26.4.0
diff --git a/platform/core/navigation/src/main/java/tools/dynamia/navigation/ActionPage.java b/platform/core/navigation/src/main/java/tools/dynamia/navigation/ActionPage.java
index 8e810d73..09e55f46 100644
--- a/platform/core/navigation/src/main/java/tools/dynamia/navigation/ActionPage.java
+++ b/platform/core/navigation/src/main/java/tools/dynamia/navigation/ActionPage.java
@@ -19,6 +19,8 @@
import tools.dynamia.actions.Action;
import tools.dynamia.actions.ActionEvent;
+import tools.dynamia.actions.Actions;
+import tools.dynamia.actions.LocalAction;
import tools.dynamia.commons.StringUtils;
import tools.dynamia.integration.Containers;
@@ -89,8 +91,8 @@ public ActionPage(String id, String name, boolean closeable, Class extends Act
*/
public void execute() {
Action action = Containers.get().findObject(actionClass);
- if (action != null) {
- action.actionPerformed(new ActionEvent(this, this));
+ if (action instanceof LocalAction localAction) {
+ localAction.actionPerformed(new ActionEvent(this, this));
}
}
diff --git a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationNode.java b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationNode.java
index d1298bdb..48a469c0 100644
--- a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationNode.java
+++ b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationNode.java
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import java.io.Serializable;
import java.util.ArrayList;
@@ -10,6 +11,7 @@
import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"id", "name", "longName", "type", "description", "icon", "internalPath", "path", "position", "featured", "file", "attributes", "children"})
public class NavigationNode implements Serializable {
diff --git a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationTree.java b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationTree.java
index c69cc735..a5fd4baa 100644
--- a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationTree.java
+++ b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationTree.java
@@ -3,6 +3,7 @@
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
+import java.util.function.Consumer;
public class NavigationTree implements Serializable {
@@ -30,6 +31,23 @@ public void setNavigation(List nodes) {
this.navigation = nodes;
}
+ public void forEachNode(Consumer action) {
+ if (navigation != null) {
+ traverse(action, navigation);
+ }
+ }
+
+ private void traverse(Consumer action, List nodes) {
+ if (navigation != null) {
+ for (NavigationNode node : nodes) {
+ action.accept(node);
+ if (node.getChildren() != null) {
+ traverse(action, node.getChildren());
+ }
+ }
+ }
+ }
+
/**
* Build default navigation tree
*
diff --git a/platform/core/reports/pom.xml b/platform/core/reports/pom.xml
index a9b0a87a..c62fe634 100644
--- a/platform/core/reports/pom.xml
+++ b/platform/core/reports/pom.xml
@@ -26,7 +26,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
diff --git a/platform/core/templates/pom.xml b/platform/core/templates/pom.xml
index 36776c4b..cb2fff38 100644
--- a/platform/core/templates/pom.xml
+++ b/platform/core/templates/pom.xml
@@ -23,7 +23,7 @@
tools.dynamia.parent
tools.dynamia
- 26.3.1
+ 26.4.0
../../../pom.xml
@@ -64,12 +64,12 @@
tools.dynamia
tools.dynamia.integration
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.commons
- 26.3.1
+ 26.4.0
diff --git a/platform/core/viewers/pom.xml b/platform/core/viewers/pom.xml
index f5815dad..776ae47e 100644
--- a/platform/core/viewers/pom.xml
+++ b/platform/core/viewers/pom.xml
@@ -25,7 +25,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
@@ -67,27 +67,27 @@
tools.dynamia
tools.dynamia.commons
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.integration
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.io
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.domain
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.actions
- 26.3.1
+ 26.4.0
org.yaml
diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/Field.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/Field.java
index b9982fa9..89cd9e02 100644
--- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/Field.java
+++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/Field.java
@@ -18,6 +18,7 @@
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import jakarta.validation.constraints.NotNull;
import tools.dynamia.actions.ActionReference;
import tools.dynamia.commons.BeanMessages;
@@ -25,6 +26,7 @@
import tools.dynamia.commons.StringUtils;
import tools.dynamia.commons.reflect.AccessMode;
import tools.dynamia.commons.reflect.PropertyInfo;
+import tools.dynamia.domain.Reference;
import tools.dynamia.domain.contraints.NotEmpty;
import tools.dynamia.domain.util.DomainUtils;
@@ -40,6 +42,8 @@
* @author Mario A. Serrano Leones
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"name", "label", "localizedLabel", "description", "localizedDescription", "fieldClass", "componentClass", "component", "visible", "index", "entity",
+ "enum", "value", "required", "action", "icon", "showIconOnly", "params"})
public class Field implements Serializable, Indexable, Cloneable {
/**
@@ -593,6 +597,14 @@ public boolean isEntity() {
}
}
+ public boolean isEnum() {
+ return propertyInfo != null && propertyInfo.isEnum() || fieldClass != null && fieldClass.isEnum();
+ }
+
+ public boolean isReference() {
+ return propertyInfo != null && propertyInfo.isAnnotationPresent(Reference.class);
+ }
+
public ActionReference getAction() {
return action;
}
diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/FieldGroup.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/FieldGroup.java
index f561c802..9a688e1d 100644
--- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/FieldGroup.java
+++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/FieldGroup.java
@@ -46,6 +46,7 @@ public class FieldGroup implements Serializable, Indexable, Cloneable {
private int index;
private boolean collapse;
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
private final Map params = new HashMap<>();
/**
diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/ViewAction.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/ViewAction.java
index 386f40ec..7eb20d84 100644
--- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/ViewAction.java
+++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/ViewAction.java
@@ -1,6 +1,7 @@
package tools.dynamia.viewers;
import tools.dynamia.actions.AbstractAction;
+import tools.dynamia.actions.AbstractLocalAction;
/**
* Base class for actions that are contextually bound to a {@link View}.
@@ -19,5 +20,5 @@
* @see AbstractAction
* @see ViewDescriptor#getActions()
*/
-public abstract class ViewAction extends AbstractAction {
+public abstract class ViewAction extends AbstractLocalAction {
}
diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/AbstractViewDescriptor.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/AbstractViewDescriptor.java
index 6bbf3bac..13b6f385 100644
--- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/AbstractViewDescriptor.java
+++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/AbstractViewDescriptor.java
@@ -108,7 +108,7 @@ public abstract class AbstractViewDescriptor implements MergeableViewDescriptor,
private Class extends ViewRenderer> customViewRenderer;
private String device = "screen";
-
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
private List actions = new ArrayList<>();
/**
diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/DefaultViewDescriptor.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/DefaultViewDescriptor.java
index d09fe2d5..f3866306 100644
--- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/DefaultViewDescriptor.java
+++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/DefaultViewDescriptor.java
@@ -17,6 +17,7 @@
package tools.dynamia.viewers.impl;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import tools.dynamia.viewers.ViewLayout;
@@ -26,8 +27,9 @@
* @author Mario A. Serrano Leones
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"id", "beanClass", "viewTypeName", "device", "autofields", "fields", "fieldGroups", "layout", "params"})
@SuppressWarnings({"rawtypes"})
-public class DefaultViewDescriptor extends AbstractViewDescriptor{
+public class DefaultViewDescriptor extends AbstractViewDescriptor {
/**
* The layout.
diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/DefaultViewLayout.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/DefaultViewLayout.java
index 0efa150a..dd09b99b 100644
--- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/DefaultViewLayout.java
+++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/impl/DefaultViewLayout.java
@@ -16,6 +16,7 @@
*/
package tools.dynamia.viewers.impl;
+import com.fasterxml.jackson.annotation.JsonInclude;
import tools.dynamia.integration.sterotypes.Component;
import tools.dynamia.viewers.ViewLayout;
@@ -29,19 +30,21 @@
* @author Mario A. Serrano Leones
*/
@Component
+@JsonInclude(JsonInclude.Include.NON_NULL)
public class DefaultViewLayout implements ViewLayout {
/**
* The map.
*/
- private final Map map = new HashMap<>();
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+ private final Map params = new HashMap<>();
/* (non-Javadoc)
* @see com.dynamia.tools.viewers.ViewLayout#getParams()
*/
@Override
public Map getParams() {
- return map;
+ return params;
}
/* (non-Javadoc)
@@ -49,13 +52,13 @@ public Map getParams() {
*/
@Override
public void addParam(String name, Object value) {
- map.put(name, value);
+ params.put(name, value);
}
@Override
public void addParams(Map params) {
if (params != null && !params.isEmpty()) {
- map.putAll(params);
+ this.params.putAll(params);
}
}
}
diff --git a/platform/core/web/pom.xml b/platform/core/web/pom.xml
index 805066fb..47cdf49d 100644
--- a/platform/core/web/pom.xml
+++ b/platform/core/web/pom.xml
@@ -29,7 +29,7 @@
tools.dynamia
tools.dynamia.parent
- 26.3.1
+ 26.4.0
../../../pom.xml
@@ -88,27 +88,27 @@
tools.dynamia
tools.dynamia.commons
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.integration
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.navigation
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.viewers
- 26.3.1
+ 26.4.0
tools.dynamia
tools.dynamia.crud
- 26.3.1
+ 26.4.0
org.springframework
diff --git a/platform/packages/README.md b/platform/packages/README.md
index 5ffe3c16..b06bd164 100644
--- a/platform/packages/README.md
+++ b/platform/packages/README.md
@@ -8,13 +8,11 @@ These packages allow frontend developers to interact with Dynamia Platform backe
## 📦 Packages
-| Package | Description |
-|---------|-------------|
-| `@dynamia-tools/sdk` | JavaScript/TypeScript client SDK for Dynamia Platform REST APIs |
-| `@dynamia-tools/vue` | Vue 3 integration — composables, components, and plugins for Dynamia Platform |
-| `@dynamia-tools/ui` | Headless UI components and utilities aligned with Dynamia Platform's view descriptors |
-| `@dynamia-tools/forms` | Dynamic form generation driven by Dynamia Platform view descriptors |
-| `@dynamia-tools/auth` | Authentication and session management helpers |
+| Package | Description |
+|--------------------------|-------------|
+| `@dynamia-tools/sdk` | JavaScript/TypeScript client SDK for Dynamia Platform REST APIs |
+| `@dynamia-tools/ui-core` | Headless UI components and utilities aligned with Dynamia Platform's view descriptors |
+| `@dynamia-tools/vue` | Vue 3 integration — composables, components, and plugins for Dynamia Platform |
> **Note:** Packages are added progressively. Check each sub-folder for its own `README.md` and `CHANGELOG.md`.
@@ -25,10 +23,8 @@ These packages allow frontend developers to interact with Dynamia Platform backe
```
packages/
├── sdk/ # @dynamia-tools/sdk – Core API client
+├── ui-core/ # @dynamia-tools/ui-core – UI components
├── vue/ # @dynamia-tools/vue – Vue 3 integration
-├── ui/ # @dynamia-tools/ui – UI components
-├── forms/ # @dynamia-tools/forms – Dynamic forms
-├── auth/ # @dynamia-tools/auth – Auth helpers
└── README.md # This file
```
diff --git a/platform/packages/package.json b/platform/packages/package.json
index 6c4be15f..dd63a183 100644
--- a/platform/packages/package.json
+++ b/platform/packages/package.json
@@ -1,33 +1,7 @@
{
- "name": "@dynamia-tools/workspace",
- "version": "26.3.1",
+ "name": "@dynamia-tools/platform-packages",
+ "version": "26.3.2",
"private": true,
- "author": "Mario Serrano",
- "repository": "https://github.com/dynamiatools/framework",
- "website": "https://dynamia.tools",
- "description": "pnpm monorepo for Dynamia Platform Node.js / TypeScript packages",
- "scripts": {
- "build": "pnpm -r run build",
- "test": "pnpm -r run test",
- "lint": "pnpm -r run lint",
- "clean": "pnpm -r run clean",
- "typecheck": "pnpm -r run typecheck",
- "publish:all": "pnpm -r publish --access public --no-git-checks"
- },
- "devDependencies": {
- "@types/node": "^22.0.0",
- "typescript": "^5.7.0",
- "vite": "^6.2.0",
- "vite-plugin-dts": "^4.5.0",
- "vitest": "^3.0.0",
- "@vitest/coverage-v8": "^3.0.0",
- "eslint": "^9.0.0",
- "prettier": "^3.5.0"
- },
- "engines": {
- "node": ">=20",
- "pnpm": ">=9"
- },
- "packageManager": "pnpm@10.31.0",
+ "description": "Platform packages workspace sub-directory (not a workspace root — see framework/package.json)",
"license": "Apache-2.0"
}
diff --git a/platform/packages/pnpm-lock.yaml b/platform/packages/pnpm-lock.yaml
index b6dd8137..d34f7b2d 100644
--- a/platform/packages/pnpm-lock.yaml
+++ b/platform/packages/pnpm-lock.yaml
@@ -54,6 +54,57 @@ 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)
+
+ 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':
@@ -524,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:
@@ -562,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==}
@@ -588,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:
@@ -626,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'}
@@ -715,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==}
@@ -1377,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'}
@@ -1766,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
@@ -1827,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
@@ -1847,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
@@ -1870,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
@@ -1902,6 +2110,8 @@ snapshots:
alien-signals@0.4.14: {}
+ alien-signals@1.0.13: {}
+
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
@@ -1982,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:
@@ -2647,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
deleted file mode 100644
index 8f7adc3d..00000000
--- a/platform/packages/pnpm-workspace.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-packages:
- - 'sdk'
-allowBuilds:
- esbuild: true
diff --git a/platform/packages/sdk/README.md b/platform/packages/sdk/README.md
index 9ab47d0d..177d4519 100644
--- a/platform/packages/sdk/README.md
+++ b/platform/packages/sdk/README.md
@@ -4,6 +4,12 @@
`@dynamia-tools/sdk` provides a fully typed, zero-dependency-at-runtime client that covers every REST endpoint exposed by a Dynamia Platform backend: application metadata, navigation tree, entity CRUD, actions, reports, files, SaaS accounts, schedules, and more.
+Public API exports include:
+
+- `DynamiaClient`, `DynamiaApiError`
+- API classes: `MetadataApi`, `ActionsApi`, `CrudResourceApi`, `CrudServiceApi`, `ReportsApi`, `FilesApi`, `SaasApi`, `ScheduleApi`
+- Core types for metadata, navigation, CRUD, reports and SaaS responses
+
---
## Table of Contents
@@ -18,10 +24,6 @@
- [CRUD Navigation API](#crud-navigation-api)
- [CRUD Service API](#crud-service-api)
- [Actions API](#actions-api)
- - [Reports API](#reports-api)
- - [Files API](#files-api)
- - [SaaS API](#saas-api)
- - [Schedule API](#schedule-api)
- [TypeScript Types](#typescript-types)
- [Error Handling](#error-handling)
- [Contributing](#contributing)
@@ -61,6 +63,7 @@ console.log(metadata.name, metadata.version);
// List entities of a CRUD page
const books = await client.crud('books').findAll();
+console.log(books.content.length, books.totalPages);
```
---
@@ -101,6 +104,8 @@ const client = new DynamiaClient({
});
```
+> Note: the SDK does not include a dedicated `login()` helper. Perform login with your preferred HTTP client (or `fetch`) and then instantiate `DynamiaClient` with cookie forwarding (`withCredentials`) or a token.
+
---
## API Reference
@@ -185,11 +190,11 @@ The navigation tree is also accessible through the metadata API (see above). Use
```typescript
const tree: NavigationTree = await client.metadata.getNavigation();
-tree.modules.forEach(module => {
+tree.navigation.forEach(module => {
console.log(module.name);
- module.groups.forEach(group => {
- group.pages.forEach(page => {
- console.log(` ${page.name} → ${page.virtualPath}`);
+ module.children?.forEach(groupOrPage => {
+ groupOrPage.children?.forEach(page => {
+ console.log(` ${page.name} → ${page.internalPath}`);
});
});
});
@@ -206,10 +211,11 @@ Auto-generated REST endpoints for every `CrudPage` registered in the platform's
const books = client.crud('store/catalog/books');
// List all (with pagination)
-const page1: CrudPage = await books.findAll({ page: 1, size: 20 });
+const page1: CrudListResult = await books.findAll({ page: 1, size: 20 });
// page1.content → Book[]
// page1.total → total records
// page1.pageSize → current page size
+// page1.totalPages → total pages
// Filter by query parameters
const filtered = await books.findAll({ q: 'clean', author: 'Martin' });
@@ -301,97 +307,6 @@ const entityResponse: ActionExecutionResponse = await client.actions.executeEnti
---
-### Reports API
-
-Access the **Reports extension** (`/api/reports`). Requires the `dynamia-reports` module installed on the server.
-
-```typescript
-// List all exportable reports
-const reports: ReportDTO[] = await client.reports.list();
-
-// Fetch report data (GET with query-string filters)
-const data = await client.reports.get('sales', 'monthly-summary', {
- year: '2026',
- month: '03',
-});
-
-// Fetch report data (POST with structured filters)
-const data2 = await client.reports.post('sales', 'monthly-summary', {
- filters: [
- { name: 'year', value: '2026' },
- { name: 'status', value: 'CLOSED' },
- ],
-});
-```
-
-**Endpoint map:**
-
-| Method | Endpoint | SDK call |
-|--------|----------|----------|
-| `GET` | `/api/reports` | `reports.list()` |
-| `GET` | `/api/reports/{group}/{endpoint}` | `reports.get(group, endpoint, params?)` |
-| `POST` | `/api/reports/{group}/{endpoint}` | `reports.post(group, endpoint, filters?)` |
-
----
-
-### Files API
-
-Download files managed by the **entity-files** extension.
-
-```typescript
-// Get a file as a Blob
-const blob: Blob = await client.files.download('profile-picture.png', 'uuid-abc-123');
-
-// Get a direct download URL (useful for )
-const url: string = client.files.getUrl('profile-picture.png', 'uuid-abc-123');
-```
-
-**Endpoint map:**
-
-| Method | Endpoint | SDK call |
-|--------|----------|----------|
-| `GET` | `/storage/{file}?uuid={uuid}` | `files.download(file, uuid)` |
-
----
-
-### SaaS API
-
-Manage multi-tenant accounts via the **SaaS extension** (`/api/saas`).
-
-```typescript
-// Get account information by UUID
-const account: AccountDTO = await client.saas.getAccount('account-uuid-here');
-```
-
-**Endpoint map:**
-
-| Method | Endpoint | SDK call |
-|--------|----------|----------|
-| `GET` | `/api/saas/account/{uuid}` | `saas.getAccount(uuid)` |
-
----
-
-### Schedule API
-
-Manually trigger periodic tasks registered in the platform.
-
-```typescript
-await client.schedule.executeMorning();
-await client.schedule.executeMidday();
-await client.schedule.executeAfternoon();
-await client.schedule.executeEvening();
-```
-
-**Endpoint map:**
-
-| Method | Endpoint | SDK call |
-|--------|----------|----------|
-| `GET` | `/schedule/execute-tasks/morning` | `schedule.executeMorning()` |
-| `GET` | `/schedule/execute-tasks/midday` | `schedule.executeMidday()` |
-| `GET` | `/schedule/execute-tasks/afternoon` | `schedule.executeAfternoon()` |
-| `GET` | `/schedule/execute-tasks/evening` | `schedule.executeEvening()` |
-
----
## TypeScript Types
@@ -400,10 +315,15 @@ Key types exported by the SDK (mirroring the Java server model):
```typescript
// Core
interface ApplicationMetadata { name: string; version: string; description?: string; /* ... */ }
-interface NavigationTree { modules: NavigationModule[]; }
-interface NavigationModule { id: string; name: string; groups: NavigationGroup[]; }
-interface NavigationGroup { id: string; name: string; pages: NavigationPage[]; }
-interface NavigationPage { id: string; name: string; virtualPath: string; prettyVirtualPath: string; }
+interface NavigationTree { navigation: NavigationNode[]; }
+interface NavigationNode {
+ id: string;
+ name: string;
+ type?: string; // "Module" | "PageGroup" | "Page" | "CrudPage" | ...
+ internalPath?: string; // route path used by frontends
+ path?: string; // display path
+ children?: NavigationNode[];
+}
// Entities
interface ApplicationMetadataEntities { entities: EntityMetadata[]; }
@@ -419,10 +339,13 @@ interface ActionExecutionResponse { message: string; status: string; code: numbe
// Views
interface ViewDescriptor { id: string; beanClass: string; viewTypeName: string; fields: ViewField[]; params: Record; }
interface ViewDescriptorMetadata { view: string; descriptor: ViewDescriptor; }
-interface ViewField { name: string; fieldClass?: string; label?: string; visible?: boolean; params: Record; }
+interface ViewField { name: string; fieldClass?: string; label?: string; visible?: boolean; required?: boolean; params: Record; }
// CRUD
-interface CrudListResult { content: T[]; total: number; page: number; pageSize: number; }
+interface CrudPageable { totalSize: number; pageSize: number; firstResult: number; page: number; pagesNumber: number; }
+interface CrudRawResponse { data: T[]; pageable: CrudPageable | null; response: string; }
+interface CrudListResult { content: T[]; total: number; page: number; pageSize: number; totalPages: number; }
+type CrudQueryParams = Record;
// Reports
interface ReportDTO { id: string; name: string; group: string; endpoint: string; description?: string; }
@@ -450,6 +373,8 @@ try {
}
```
+`DynamiaApiError` also includes `status`, `url`, and `body` to support centralized logging and UI error mapping.
+
---
## Contributing
diff --git a/platform/packages/sdk/package.json b/platform/packages/sdk/package.json
index 8794cb95..9c0bd382 100644
--- a/platform/packages/sdk/package.json
+++ b/platform/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@dynamia-tools/sdk",
- "version": "26.3.1",
+ "version": "26.3.2",
"website": "https://dynamia.tools",
"description": "Official JavaScript / TypeScript client SDK for Dynamia Platform REST APIs",
"keywords": [
@@ -28,6 +28,7 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
+ "development": "./src/index.ts",
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
diff --git a/platform/packages/sdk/src/client.ts b/platform/packages/sdk/src/client.ts
index 098813b5..0231a0c6 100644
--- a/platform/packages/sdk/src/client.ts
+++ b/platform/packages/sdk/src/client.ts
@@ -3,15 +3,17 @@ import { MetadataApi } from './metadata/api.js';
import { ActionsApi } from './metadata/actions.js';
import { CrudResourceApi } from './cruds/crud-resource.js';
import { CrudServiceApi } from './cruds/crud-service.js';
-import { ReportsApi } from './reports/api.js';
-import { FilesApi } from './files/api.js';
-import { SaasApi } from './saas/api.js';
import { ScheduleApi } from './schedule/api.js';
import type { DynamiaClientConfig } from './types.js';
/**
* Root client for the Dynamia Platform REST API.
*
+ * Extension-specific APIs (reports, files, saas) are available as separate packages:
+ * - `@dynamia-tools/reports-sdk` → `new ReportsApi(client.http)`
+ * - `@dynamia-tools/files-sdk` → `new FilesApi(client.http)`
+ * - `@dynamia-tools/saas-sdk` → `new SaasApi(client.http)`
+ *
* @example
* ```typescript
* const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: '...' });
@@ -20,18 +22,13 @@ import type { DynamiaClientConfig } from './types.js';
* ```
*/
export class DynamiaClient {
- private readonly http: HttpClient;
+ /** @internal exposed so extension SDK packages can build their own API instances */
+ readonly http: HttpClient;
/** Application metadata API */
readonly metadata: MetadataApi;
/** Global & entity actions API */
readonly actions: ActionsApi;
- /** Reports extension API */
- readonly reports: ReportsApi;
- /** Entity-files extension API */
- readonly files: FilesApi;
- /** SaaS extension API */
- readonly saas: SaasApi;
/** Scheduled-tasks API */
readonly schedule: ScheduleApi;
@@ -39,9 +36,6 @@ export class DynamiaClient {
this.http = new HttpClient(config);
this.metadata = new MetadataApi(this.http);
this.actions = new ActionsApi(this.http);
- this.reports = new ReportsApi(this.http);
- this.files = new FilesApi(this.http);
- this.saas = new SaasApi(this.http);
this.schedule = new ScheduleApi(this.http);
}
@@ -74,6 +68,23 @@ export class DynamiaClient {
crudService(className: string): CrudServiceApi {
return new CrudServiceApi(this.http, className);
}
-}
-
+ /**
+ * Invalidates the in-memory ViewDescriptor cache held by {@link MetadataApi}.
+ *
+ * Call this after a backend hot-reload or when you know a descriptor has changed,
+ * so the next request fetches a fresh copy from the server.
+ *
+ * @param className - When provided, only entries for that entity class are removed.
+ * When omitted, the entire cache is cleared.
+ *
+ * @example
+ * // Invalidate just one entity
+ * client.clearViewDescriptorCache('mybookstore.domain.Book');
+ * // Invalidate everything
+ * client.clearViewDescriptorCache();
+ */
+ clearViewDescriptorCache(className?: string): void {
+ this.metadata.clearViewCache(className);
+ }
+}
diff --git a/platform/packages/sdk/src/cruds/crud-service.ts b/platform/packages/sdk/src/cruds/crud-service.ts
index b153335c..7a78834e 100644
--- a/platform/packages/sdk/src/cruds/crud-service.ts
+++ b/platform/packages/sdk/src/cruds/crud-service.ts
@@ -15,7 +15,14 @@ export class CrudServiceApi {
this.basePath = `/crud-service/${encodeURIComponent(className)}`;
}
- /** POST/PUT /crud-service/{className} — Save (create or update depending on entity ID) */
+ /**
+ * `POST /crud-service/{className}` — Persist an entity (create or update).
+ *
+ * The Java controller uses a single `@PostMapping` for both create and
+ * update operations. Whether the request results in an INSERT or UPDATE is
+ * determined server-side based on the presence of a non-null entity ID.
+ * There is no separate PUT endpoint.
+ */
save(entity: Partial): Promise {
return this.http.post(this.basePath, entity);
}
diff --git a/platform/packages/sdk/src/cruds/types.ts b/platform/packages/sdk/src/cruds/types.ts
index 0ce77206..0e334451 100644
--- a/platform/packages/sdk/src/cruds/types.ts
+++ b/platform/packages/sdk/src/cruds/types.ts
@@ -14,16 +14,16 @@
* | `pagesNumber` | `int` | `pagesNumber` |
*/
export interface CrudPageable {
- /** Total number of records across all pages (`DataPaginator.totalSize` — Java `long`) */
- totalSize: number;
- /** Number of records per page (`DataPaginator.pageSize` — default 30) */
- pageSize: number;
- /** Zero-based offset of the first record on this page (`DataPaginator.firstResult`) */
- firstResult: number;
- /** Current 1-based page number (`DataPaginator.page`) */
- page: number;
- /** Total number of pages (`DataPaginator.pagesNumber`) */
- pagesNumber: number;
+ /** Total number of records across all pages (`DataPaginator.totalSize` — Java `long`) */
+ totalSize: number;
+ /** Number of records per page (`DataPaginator.pageSize` — default 30) */
+ pageSize: number;
+ /** Zero-based offset of the first record on this page (`DataPaginator.firstResult`) */
+ firstResult: number;
+ /** Current 1-based page number (`DataPaginator.page`) */
+ page: number;
+ /** Total number of pages (`DataPaginator.pagesNumber`) */
+ pagesNumber: number;
}
/**
@@ -40,15 +40,15 @@ export interface CrudPageable {
* `pageable` is `null` when the result is not paginated (e.g. a flat list endpoint).
*/
export interface CrudRawResponse {
- /** The page records */
- data: T[];
- /**
- * Pagination metadata. `null` when the response is not paginated
- * (`@JsonInclude(JsonInclude.Include.NON_NULL)` in Java — field may be absent from JSON).
- */
- pageable: CrudPageable | null;
- /** Status string, typically `"OK"` */
- response: string;
+ /** The page records */
+ data: T[];
+ /**
+ * Pagination metadata. `null` when the response is not paginated
+ * (`@JsonInclude(JsonInclude.Include.NON_NULL)` in Java — field may be absent from JSON).
+ */
+ pageable: CrudPageable | null;
+ /** Status string, typically `"OK"` */
+ response: string;
}
/**
@@ -56,16 +56,18 @@ export interface CrudRawResponse {
* The SDK maps `CrudRawResponse` → `CrudListResult` so consumers never deal with the raw envelope.
*/
export interface CrudListResult {
- /** The records for this page */
- content: T[];
- /** Total number of records across all pages (`DataPaginator.totalSize`) */
- total: number;
- /** Current 1-based page number (`DataPaginator.page`) */
- page: number;
- /** Number of records per page (`DataPaginator.pageSize`) */
- pageSize: number;
- /** Total number of pages (`DataPaginator.pagesNumber`) */
- totalPages: number;
+ /** The records for this page */
+ content: T[];
+ /** Total number of records across all pages (`DataPaginator.totalSize`) */
+ total: number;
+ /** Current 1-based page number (`DataPaginator.page`) */
+ page: number;
+ /** Number of records per page (`DataPaginator.pageSize`) */
+ pageSize: number;
+ /** Total number of pages (`DataPaginator.pagesNumber`) */
+ totalPages: number;
}
export type CrudQueryParams = Record;
+
+
diff --git a/platform/packages/sdk/src/http.ts b/platform/packages/sdk/src/http.ts
index f85e19c2..2900e0cb 100644
--- a/platform/packages/sdk/src/http.ts
+++ b/platform/packages/sdk/src/http.ts
@@ -55,6 +55,17 @@ export class HttpClient {
private buildUrl(path: string, params?: Record): string {
const base = this.config.baseUrl.replace(/\/$/, '');
const normalized = path.startsWith('/') ? path : `/${path}`;
+
+ // Empty baseUrl → relative path (e.g. Vite proxy, same-origin usage)
+ if (!base) {
+ if (!params) return normalized;
+ const query = Object.entries(params)
+ .filter(([, v]) => v !== undefined && v !== null)
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
+ .join('&');
+ return query ? `${normalized}?${query}` : normalized;
+ }
+
const url = new URL(`${base}${normalized}`);
if (params) {
@@ -90,6 +101,10 @@ export class HttpClient {
headers: this.buildHeaders(),
};
+ if (this.config.corsMode) {
+ init.mode = this.config.corsMode;
+ }
+
if (this.config.withCredentials) {
init.credentials = 'include';
}
diff --git a/platform/packages/sdk/src/index.ts b/platform/packages/sdk/src/index.ts
index 240205cd..55ed7f66 100644
--- a/platform/packages/sdk/src/index.ts
+++ b/platform/packages/sdk/src/index.ts
@@ -1,59 +1,54 @@
// ── Main client ─────────────────────────────────────────────────────────────
-export { DynamiaClient } from './client.js';
+export {DynamiaClient} from './client.js';
+
+// ── HTTP client (exported so extension SDK packages can type-check against it) ──
+export {HttpClient} from './http.js';
// ── Error ────────────────────────────────────────────────────────────────────
-export { DynamiaApiError } from './errors.js';
+export {DynamiaApiError} from './errors.js';
// ── Core types ────────────────────────────────────────────────────────────────
-export type { DynamiaClientConfig } from './types.js';
+export type {DynamiaClientConfig} from './types.js';
// ── Metadata module ──────────────────────────────────────────────────────────
-export { MetadataApi, ActionsApi } from './metadata/index.js';
+export {
+ MetadataApi, ActionsApi, resolveViewFieldType, resolveFieldType, resolveFieldEnumConstants
+} from './metadata/index.js';
+export type {ExecuteActionOptions} from './metadata/index.js';
export type {
- // Application
- ApplicationMetadata,
- // Navigation
- NavigationTree,
- NavigationModule,
- NavigationGroup,
- NavigationPage,
- // Metadata
- BasicMetadata,
- ApplicationMetadataEntities,
- EntityMetadata,
- ApplicationMetadataActions,
- ActionMetadata,
- // Actions
- ActionExecutionRequest,
- ActionExecutionResponse,
- // Views
- ViewDescriptorMetadata,
- ViewDescriptor,
- ViewField,
+ // Application
+ ApplicationMetadata,
+ // Navigation
+ NavigationTree,
+ NavigationNode,
+ // Metadata
+ BasicMetadata,
+ ApplicationMetadataEntities,
+ EntityMetadata,
+ EntityReference,
+ ApplicationMetadataActions,
+ ActionType,
+ ActionMetadata,
+ // Actions
+ ActionExecutionRequest,
+ ActionExecutionResponse,
+ // Views
+ ViewDescriptorMetadata,
+ ActionReference,
+ ViewLayout,
+ ViewFieldGroup,
+ ViewDescriptor,
+ ViewField,
} from './metadata/index.js';
// ── CRUD module ───────────────────────────────────────────────────────────────
-export { CrudResourceApi, CrudServiceApi } from './cruds/index.js';
+export {CrudResourceApi, CrudServiceApi} from './cruds/index.js';
export type {
- CrudListResult,
- CrudQueryParams,
- CrudRawResponse,
- CrudPageable,
+ CrudListResult,
+ CrudQueryParams,
+ CrudRawResponse,
+ CrudPageable,
} from './cruds/index.js';
-// ── Reports module ────────────────────────────────────────────────────────────
-export { ReportsApi } from './reports/index.js';
-export type {
- ReportDTO,
- ReportFilter,
- ReportFilters,
-} from './reports/index.js';
-
-// ── SaaS module ───────────────────────────────────────────────────────────────
-export { SaasApi } from './saas/index.js';
-export type { AccountDTO } from './saas/index.js';
-
-// ── API classes (files & schedule — core extensions) ─────────────────────────
-export { FilesApi } from './files/index.js';
-export { ScheduleApi } from './schedule/index.js';
-
+// ── Schedule API (platform-core feature) ─────────────────────────────────────
+export {ScheduleApi} from './schedule/index.js';
diff --git a/platform/packages/sdk/src/metadata/actions.ts b/platform/packages/sdk/src/metadata/actions.ts
index 27b9c728..456264b4 100644
--- a/platform/packages/sdk/src/metadata/actions.ts
+++ b/platform/packages/sdk/src/metadata/actions.ts
@@ -1,5 +1,10 @@
import type { HttpClient } from '../http.js';
-import type { ActionExecutionRequest, ActionExecutionResponse } from './types.js';
+import type { ActionExecutionRequest, ActionExecutionResponse, ActionMetadata } from './types.js';
+
+export interface ExecuteActionOptions {
+ /** Explicit entity class name for ClassAction / CrudAction execution. */
+ className?: string | null;
+}
/**
* Execute platform actions (global or entity-scoped).
@@ -36,4 +41,65 @@ export class ActionsApi {
request ?? {},
);
}
+
+ /**
+ * Execute an action from metadata, automatically choosing the global or
+ * entity-scoped endpoint.
+ */
+ execute(
+ action: string | ActionMetadata,
+ request?: ActionExecutionRequest,
+ options?: ExecuteActionOptions,
+ ): Promise {
+ if (typeof action === 'string') {
+ return this.executeGlobal(action, request);
+ }
+
+ const className = this.resolveEntityClassName(action, request, options);
+ if (className) {
+ return this.executeEntity(className, action.id, request);
+ }
+
+ return this.executeGlobal(action.id, request);
+ }
+
+ private resolveEntityClassName(
+ action: ActionMetadata,
+ request?: ActionExecutionRequest,
+ options?: ExecuteActionOptions,
+ ): string | null {
+ const explicitClassName = options?.className?.trim();
+ if (explicitClassName) {
+ return explicitClassName;
+ }
+
+ const requestDataType = request?.dataType?.trim();
+ if (requestDataType) {
+ return requestDataType;
+ }
+
+ const applicableClasses = (action.applicableClasses ?? []).filter(
+ className => className && className.toLowerCase() !== 'all',
+ );
+
+ if (applicableClasses.length === 1) {
+ return applicableClasses[0] ?? null;
+ }
+
+ if (this.isEntityScopedAction(action)) {
+ throw new Error(
+ `Action "${action.id}" requires an entity class name. `
+ + 'Provide ExecuteActionOptions.className or ActionExecutionRequest.dataType.',
+ );
+ }
+
+ return null;
+ }
+
+ private isEntityScopedAction(action: ActionMetadata): boolean {
+ return action.type === 'ClassAction'
+ || action.type === 'CrudAction'
+ || (action.applicableClasses?.length ?? 0) > 0
+ || (action.applicableStates?.length ?? 0) > 0;
+ }
}
diff --git a/platform/packages/sdk/src/metadata/api.ts b/platform/packages/sdk/src/metadata/api.ts
index 7d1d983d..edc85ac8 100644
--- a/platform/packages/sdk/src/metadata/api.ts
+++ b/platform/packages/sdk/src/metadata/api.ts
@@ -1,59 +1,132 @@
-import type { HttpClient } from '../http.js';
+import type {HttpClient} from '../http.js';
import type {
- ApplicationMetadata,
- ApplicationMetadataActions,
- ApplicationMetadataEntities,
- EntityMetadata,
- NavigationTree,
- ViewDescriptor,
- ViewDescriptorMetadata,
+ ApplicationMetadata,
+ ApplicationMetadataActions,
+ ApplicationMetadataEntities,
+ EntityMetadata,
+ EntityReference,
+ NavigationTree,
+ ViewDescriptor,
} from './types.js';
/**
* Provides access to application metadata endpoints.
* Base path: /api/app/metadata
+ *
+ * View descriptors are cached in-memory because they rarely change at runtime.
+ * Call {@link clearViewCache} to invalidate after a hot-reload or forced refresh.
*/
export class MetadataApi {
- private readonly http: HttpClient;
-
- constructor(http: HttpClient) {
- this.http = http;
- }
-
- /** GET /api/app/metadata — Application-level metadata */
- getApp(): Promise {
- return this.http.get('/api/app/metadata');
- }
-
- /** GET /api/app/metadata/navigation — Full navigation tree */
- getNavigation(): Promise {
- return this.http.get('/api/app/metadata/navigation');
- }
-
- /** GET /api/app/metadata/actions — All global actions */
- getGlobalActions(): Promise {
- return this.http.get('/api/app/metadata/actions');
- }
-
- /** GET /api/app/metadata/entities — All entity metadata */
- getEntities(): Promise {
- return this.http.get('/api/app/metadata/entities');
- }
-
- /** GET /api/app/metadata/entities/{className} — Single entity metadata */
- getEntity(className: string): Promise {
- return this.http.get(`/api/app/metadata/entities/${encodeURIComponent(className)}`);
- }
-
- /** GET /api/app/metadata/entities/{className}/views — All view descriptors for an entity */
- getEntityViews(className: string): Promise {
- return this.http.get(`/api/app/metadata/entities/${encodeURIComponent(className)}/views`);
- }
-
- /** GET /api/app/metadata/entities/{className}/views/{view} — Specific view descriptor */
- getEntityView(className: string, view: string): Promise {
- return this.http.get(
- `/api/app/metadata/entities/${encodeURIComponent(className)}/views/${encodeURIComponent(view)}`,
- );
- }
+ private readonly http: HttpClient;
+
+ /** Cache: `"className:viewType"` → single ViewDescriptor */
+ private readonly _viewCache = new Map();
+ /** Cache: `className` → full list of ViewDescriptors */
+ private readonly _viewsCache = new Map();
+
+ constructor(http: HttpClient) {
+ this.http = http;
+ }
+
+ /** GET /api/app/metadata — Application-level metadata */
+ getApp(): Promise {
+ return this.http.get('/api/app/metadata');
+ }
+
+ /** GET /api/app/metadata/navigation — Full navigation tree */
+ getNavigation(): Promise {
+ return this.http.get('/api/app/metadata/navigation');
+ }
+
+ /** GET /api/app/metadata/actions — All global actions */
+ getGlobalActions(): Promise {
+ return this.http.get('/api/app/metadata/actions');
+ }
+
+ /** GET /api/app/metadata/entities — All entity metadata */
+ getEntities(): Promise {
+ return this.http.get('/api/app/metadata/entities');
+ }
+
+ /** GET /api/app/metadata/entities/{className} — Single entity metadata */
+ getEntity(className: string): Promise {
+ return this.http.get(`/api/app/metadata/entities/${encodeURIComponent(className)}`);
+ }
+
+ /** GET /api/app/metadata/entities/{className} — Single entity metadata */
+ getEntityReference(alias: string, id: string | number): Promise {
+ return this.http.get(`/api/app/metadata/entities/ref/${encodeURIComponent(alias)}/${encodeURIComponent(id)}`);
+ }
+
+ /** GET /api/app/metadata/entities/{className} — Single entity metadata */
+ findEntityReferences(alias: string, query: string): Promise {
+ return this.http.get(`/api/app/metadata/entities/ref/${encodeURIComponent(alias)}/search?q=${encodeURIComponent(query)}`);
+ }
+
+ /**
+ * GET /api/app/metadata/entities/{className}/views — All view descriptors for an entity.
+ *
+ * Results are cached after the first successful fetch.
+ * Individual descriptors are also stored in the per-view cache.
+ */
+ async getEntityViews(className: string): Promise {
+ const cached = this._viewsCache.get(className);
+ if (cached !== undefined) return cached;
+
+ const descriptors = await this.http.get(
+ `/api/app/metadata/entities/${encodeURIComponent(className)}/views`,
+ );
+
+ this._viewsCache.set(className, descriptors);
+ // Populate per-view cache from the bulk result to avoid redundant round-trips
+ for (const d of descriptors) {
+ if (d.view) {
+ this._viewCache.set(`${className}:${d.view}`, d);
+ }
+ }
+ return descriptors;
+ }
+
+ /**
+ * GET /api/app/metadata/entities/{className}/views/{view} — Specific view descriptor.
+ *
+ * The result is cached after the first successful fetch.
+ */
+ async getEntityView(className: string, view: string): Promise {
+ const key = `${className}:${view}`;
+ const cached = this._viewCache.get(key);
+ if (cached !== undefined) return cached;
+
+ const descriptor = await this.http.get(
+ `/api/app/metadata/entities/${encodeURIComponent(className)}/views/${encodeURIComponent(view)}`,
+ );
+
+ this._viewCache.set(key, descriptor);
+ return descriptor;
+ }
+
+ /**
+ * Clears the in-memory ViewDescriptor cache.
+ *
+ * @param className - When provided, only the cache entries for that entity class
+ * are removed. When omitted, the entire cache is cleared.
+ *
+ * @example
+ * // invalidate a single entity after a backend hot-reload
+ * client.metadata.clearViewCache('mybookstore.domain.Book');
+ * // invalidate everything
+ * client.metadata.clearViewCache();
+ */
+ clearViewCache(className?: string): void {
+ if (className !== undefined) {
+ this._viewsCache.delete(className);
+ const prefix = `${className}:`;
+ for (const key of this._viewCache.keys()) {
+ if (key.startsWith(prefix)) this._viewCache.delete(key);
+ }
+ } else {
+ this._viewCache.clear();
+ this._viewsCache.clear();
+ }
+ }
}
diff --git a/platform/packages/sdk/src/metadata/index.ts b/platform/packages/sdk/src/metadata/index.ts
index b01ba7a4..78b1b74e 100644
--- a/platform/packages/sdk/src/metadata/index.ts
+++ b/platform/packages/sdk/src/metadata/index.ts
@@ -1,19 +1,25 @@
-export { MetadataApi } from './api.js';
-export { ActionsApi } from './actions.js';
+export {MetadataApi} from './api.js';
+export {ActionsApi} from './actions.js';
+export type {ExecuteActionOptions} from './actions.js';
export type {
- BasicMetadata,
- ApplicationMetadata,
- NavigationTree,
- NavigationModule,
- NavigationGroup,
- NavigationPage,
- ApplicationMetadataEntities,
- EntityMetadata,
- ApplicationMetadataActions,
- ActionMetadata,
- ActionExecutionRequest,
- ActionExecutionResponse,
- ViewDescriptorMetadata,
- ViewDescriptor,
- ViewField,
+ BasicMetadata,
+ ApplicationMetadata,
+ NavigationTree,
+ NavigationNode,
+ ApplicationMetadataEntities,
+ EntityMetadata,
+ EntityReference,
+ ApplicationMetadataActions,
+ ActionType,
+ ActionMetadata,
+ ActionExecutionRequest,
+ ActionExecutionResponse,
+ ViewDescriptorMetadata,
+ ActionReference,
+ ViewLayout,
+ ViewFieldGroup,
+ ViewDescriptor,
+ ViewField,
} from './types.js';
+
+export {resolveFieldType, resolveViewFieldType, resolveFieldEnumConstants} from './types.js'
diff --git a/platform/packages/sdk/src/metadata/types.ts b/platform/packages/sdk/src/metadata/types.ts
index 06b15c23..4d96127a 100644
--- a/platform/packages/sdk/src/metadata/types.ts
+++ b/platform/packages/sdk/src/metadata/types.ts
@@ -3,112 +3,421 @@
// ── Basic / shared ─────────────────────────────────────────────────────────
export interface BasicMetadata {
- id: string;
- name: string;
- endpoint?: string;
- description?: string;
- icon?: string;
+ id: string;
+ name: string;
+ endpoint?: string;
+ description?: string;
+ icon?: string;
}
// ── Application metadata ───────────────────────────────────────────────────
-export interface ApplicationMetadata {
- name: string;
- version: string;
- description?: string;
- logo?: string;
- url?: string;
- modules?: NavigationModule[];
+/**
+ * Mirrors `tools.dynamia.app.ApplicationMetadata` (extends BasicMetadata).
+ *
+ * Java serialises all NON_NULL fields; optional fields below may be absent
+ * from the JSON payload when the application has not configured them.
+ */
+export interface ApplicationMetadata extends BasicMetadata {
+ /** Application display version */
+ version?: string;
+ /** Human-readable title (may differ from `name`) */
+ title?: string;
+ /** UI template / theme key */
+ template?: string;
+ /** Author or organisation name */
+ author?: string;
+ /** Public URL of the running application */
+ url?: string;
+ /** Path to the application logo asset */
+ logo?: string;
}
// ── Navigation ─────────────────────────────────────────────────────────────
export interface NavigationTree {
- modules: NavigationModule[];
+ navigation: NavigationNode[];
}
-export interface NavigationModule {
- id: string;
- name: string;
- description?: string;
- icon?: string;
- groups: NavigationGroup[];
-}
-
-export interface NavigationGroup {
- id: string;
- name: string;
- description?: string;
- icon?: string;
- pages: NavigationPage[];
-}
-
-export interface NavigationPage {
- id: string;
- name: string;
- description?: string;
- icon?: string;
- virtualPath: string;
- prettyVirtualPath: string;
- pageClass?: string;
+/**
+ * A node in the navigation tree. The `type` field indicates the kind of element:
+ * - `"Module"` — top-level module (children are groups or pages)
+ * - `"PageGroup"` — group within a module (children are pages)
+ * - `"Page"` — leaf page (has `internalPath` / `path`, no children)
+ */
+export interface NavigationNode {
+ id: string;
+ name: string;
+ longName?: string;
+ /** Simple class name of the navigation element: "Module", "PageGroup", "Page", etc. */
+ type?: string;
+ description?: string;
+ icon?: string;
+ /** Virtual path (e.g. /pages/store/books) — use for routing */
+ internalPath?: string;
+ /** Pretty/display path */
+ path?: string;
+ /** Ordering hint — Java `Double`, nullable */
+ position?: number;
+ /** Whether this node should appear in featured/shortcut areas — Java `Boolean`, nullable */
+ featured?: boolean;
+ children?: NavigationNode[];
+ attributes?: Record;
+ /** Source file path for page nodes */
+ file?: string;
}
// ── Entity metadata ────────────────────────────────────────────────────────
export interface ApplicationMetadataEntities {
- entities: EntityMetadata[];
+ entities: EntityMetadata[];
}
+/**
+ * Mirrors `tools.dynamia.app.EntityMetadata`.
+ *
+ * `descriptors` lists lightweight view references for this entity — note that
+ * the actual `ViewDescriptor` content must be fetched separately via
+ * `MetadataApi.getEntityViews()` or `MetadataApi.getEntityView()`.
+ */
export interface EntityMetadata extends BasicMetadata {
- className: string;
- actions: ActionMetadata[];
- descriptors: ViewDescriptorMetadata[];
- actionsEndpoint: string;
+ className: string;
+ actions: ActionMetadata[];
+ descriptors: ViewDescriptorMetadata[];
+ actionsEndpoint: string;
+ /**
+ * REST endpoint that returns the full list of ViewDescriptor objects for
+ * this entity (e.g. `/api/metadata/entities/MyClass/views`).
+ */
+ viewsEndpoint?: string;
+}
+
+/**
+ * Mirrors `tools.dynamia.domain.EntityReference`, a lightweight reference to an entity instance used in action parameters and elsewhere.
+ *
+ * All fields are nullable in Java and may be absent from the JSON response.
+ */
+export interface EntityReference {
+ id?: string;
+ name?: string;
+ className?: string;
+ description?: string;
+ attributes?: Record;
}
// ── Actions ────────────────────────────────────────────────────────────────
export interface ApplicationMetadataActions {
- actions: ActionMetadata[];
+ actions: ActionMetadata[];
}
+export type ActionType = 'Action' | 'ClassAction' | 'CrudAction' | string;
+
+/**
+ * Mirrors `tools.dynamia.actions.ActionMetadata`.
+ *
+ * All nullable fields are annotated `@JsonInclude(NON_NULL)` in Java and may
+ * therefore be absent from the JSON response.
+ */
export interface ActionMetadata extends BasicMetadata {
- actionClass?: string;
- params?: Record;
+ /** Logical server-side action type: Action, ClassAction or CrudAction */
+ type?: ActionType;
+ /** Simple Java class name of the action implementation */
+ className?: string;
+ /** Fully-qualified class name of the Java action implementation */
+ actionClass?: string;
+ /** Optional grouping label for the action */
+ group?: string;
+ /** Custom renderer key for the action button/widget */
+ renderer?: string;
+ /** Simple class names of entity types this action applies to */
+ applicableClasses?: string[];
+ /** Entity state names in which this action is available */
+ applicableStates?: string[];
}
+/**
+ * Request body sent to `POST /api/actions/{actionClass}`.
+ *
+ * Mirrors `tools.dynamia.actions.ActionExecutionRequest`.
+ * `data` is typed as `unknown` because the Java field is `Object` — it can
+ * carry any JSON value (scalar, array, or object).
+ */
export interface ActionExecutionRequest {
- data?: Record;
- params?: Record;
+ /** The entity / payload on which the action should operate */
+ data?: unknown;
+ /** Arbitrary extra parameters forwarded to the action */
+ params?: Record;
+ /** Identifies the UI component or view that triggered the action */
+ source?: string;
+ /** Simple class name of the entity being acted upon */
+ dataType?: string;
+ /** Primary key of the entity being acted upon */
+ dataId?: unknown;
+ /** Display name of the entity being acted upon */
+ dataName?: string;
}
+/**
+ * Response body returned by action execution endpoints.
+ *
+ * Mirrors `tools.dynamia.actions.ActionExecutionResponse`.
+ * Note: the field is `statusCode` (int) in Java — NOT `code`.
+ */
export interface ActionExecutionResponse {
- message: string;
- status: string;
- code: number;
- data?: unknown;
+ /** Human-readable message produced by the action */
+ message?: string;
+ /** Logical status label, e.g. `"SUCCESS"` or `"ERROR"` */
+ status?: string;
+ /**
+ * Numeric status code produced by the action.
+ * Serialised as `"statusCode"` in JSON (Java field `private int statusCode`).
+ */
+ statusCode?: number;
+ /** Payload returned by the action (any JSON value) */
+ data?: unknown;
+ /** Arbitrary response parameters */
+ params?: Record;
+ /** Echo of the source field from the request */
+ source?: string;
+ /** Simple class name of the entity that was acted upon */
+ dataType?: string;
+ /** Primary key of the entity that was acted upon */
+ dataId?: unknown;
+ /** Display name of the entity that was acted upon */
+ dataName?: string;
}
// ── View descriptors ───────────────────────────────────────────────────────
+/**
+ * Lightweight reference to a view descriptor associated with an entity.
+ *
+ * Mirrors the serialised form of `tools.dynamia.app.ViewDescriptorMetadata`.
+ *
+ * ⚠️ The `descriptor` field that exists in the Java class is annotated with
+ * `@JsonIgnore` and is therefore **never** included in the JSON response.
+ * To obtain the full `ViewDescriptor`, call `MetadataApi.getEntityViews()` or
+ * `MetadataApi.getEntityView()`, which hit the dedicated `/views` endpoint and
+ * return `ViewDescriptor[]` / `ViewDescriptor` directly.
+ */
export interface ViewDescriptorMetadata {
- view: string;
- descriptor: ViewDescriptor;
+ /** Unique identifier of this metadata entry */
+ id?: string;
+ /** View type name (e.g. `"form"`, `"table"`, `"tree"`) */
+ view?: string;
+ /** Target device class (e.g. `"desktop"`, `"mobile"`) */
+ device?: string;
+ /** Fully-qualified Java class name of the view bean */
+ beanClass?: string;
+ /** REST endpoint that returns the full ViewDescriptor for this view */
+ endpoint?: string;
}
+/** Mirrors tools.dynamia.actions.ActionReference */
+export interface ActionReference {
+ id: string;
+ label?: string;
+ description?: string;
+ icon?: string;
+ width?: string;
+ visible?: boolean;
+ type?: string;
+ attributes?: Record;
+}
+
+/** Mirrors tools.dynamia.viewers.ViewLayout */
+export interface ViewLayout {
+ params: Record;
+}
+
+/**
+ * Mirrors tools.dynamia.viewers.FieldGroup.
+ *
+ * The `fields` array carries field *names* serialized by the Java
+ * `@JsonProperty("fields") getFieldsNames()` method.
+ *
+ * `params` may be absent from the JSON response when the group has no
+ * parameters (`@JsonInclude(NON_DEFAULT)` — empty Map is omitted).
+ */
+export interface ViewFieldGroup {
+ name: string;
+ label?: string;
+ description?: string;
+ icon?: string;
+ index?: number;
+ collapse?: boolean;
+ /** Arbitrary layout parameters — absent when empty (`@JsonInclude(NON_DEFAULT)`) */
+ params?: Record;
+ /** Ordered list of field names belonging to this group */
+ fields?: string[];
+}
+
+/** Mirrors tools.dynamia.viewers.ViewDescriptor */
export interface ViewDescriptor {
- id: string;
- beanClass: string;
- viewTypeName: string;
- fields: ViewField[];
- params: Record;
+ id: string;
+ /** Fully qualified class name of the target domain class */
+ beanClass: string;
+ /** View type name — matches the JSON "view" key produced by Java's @JsonProperty("view") */
+ view: string;
+ fields: ViewField[];
+ fieldGroups?: ViewFieldGroup[];
+ layout?: ViewLayout;
+ params: Record;
+ messages?: string;
+ device?: string;
+ autofields?: boolean;
+ actions?: ActionReference[];
+ /** ID of the parent descriptor this one extends */
+ extends?: string;
+ viewCustomizerClass?: string;
+ customViewRenderer?: string;
}
+/**
+ * Mirrors tools.dynamia.viewers.Field.
+ *
+ * `params` may be absent from the JSON response when the field has no
+ * parameters (`@JsonInclude(NON_DEFAULT)` — empty Map is omitted).
+ */
export interface ViewField {
- name: string;
- fieldClass?: string;
- label?: string;
- visible?: boolean;
- required?: boolean;
- params: Record;
+ name: string;
+ /** Fully qualified class name of the field type */
+ fieldClass?: string;
+ label?: string;
+ description?: string;
+ /** Component name used to render the field */
+ component?: string;
+ visible?: boolean;
+ required?: boolean;
+ optional?: boolean;
+ index?: number;
+ icon?: string;
+ showIconOnly?: boolean;
+ path?: string;
+ variable?: string;
+ temporal?: boolean;
+ action?: ActionReference;
+ entity?: boolean;
+ enum?: boolean;
+ localizedLabel?: string;
+ localizedDescription?: string;
+ /** Arbitrary field parameters — absent when empty (`@JsonInclude(NON_DEFAULT)`) */
+ params?: Record;
+}
+
+// ── ViewField utilities ────────────────────────────────────────────────────
+
+/**
+ * Well-known Java fully-qualified class names → simple type token.
+ * Extend this map to add more mappings.
+ */
+const FIELD_CLASS_TYPE_MAP: Record = {
+ // Text
+ "java.lang.String": "text",
+ "java.lang.Character": "text",
+ // Integer numbers
+ "java.lang.Integer": "number",
+ "int": "number",
+ "java.lang.Long": "number",
+ "long": "number",
+ "java.lang.Short": "number",
+ "short": "number",
+ "java.lang.Byte": "number",
+ "byte": "number",
+ "java.math.BigInteger": "number",
+ // Decimal numbers
+ "java.lang.Double": "decimal",
+ "double": "decimal",
+ "java.lang.Float": "decimal",
+ "float": "decimal",
+ "java.math.BigDecimal": "decimal",
+ // Boolean
+ "java.lang.Boolean": "boolean",
+ "boolean": "boolean",
+ // Date / time
+ "java.time.LocalDate": "date",
+ "java.sql.Date": "date",
+ "java.time.LocalTime": "time",
+ "java.time.LocalDateTime": "datetime",
+ "java.time.ZonedDateTime": "datetime",
+ "java.time.OffsetDateTime": "datetime",
+ "java.time.Instant": "datetime",
+ "java.util.Date": "datetime",
+ "java.sql.Timestamp": "datetime",
+};
+
+/**
+ * Converts a PascalCase or camelCase identifier to kebab-case.
+ * e.g. `StockStatus` → `stock-status`, `myField` → `my-field`
+ */
+function toKebabCase(name: string): string {
+ return name
+ .replace(/([A-Z])/g, (letter, _match, offset) =>
+ offset > 0 ? `-${letter.toLowerCase()}` : letter.toLowerCase()
+ )
+ .toLowerCase();
+}
+
+/**
+ * Maps a `ViewField.fieldClass` (fully-qualified Java class name) to a
+ * simple type token suitable for UI rendering.
+ *
+ * - Known primitive / standard-library types are mapped to canonical tokens
+ * (`"text"`, `"number"`, `"decimal"`, `"boolean"`, `"date"`, `"time"`, `"datetime"`).
+ * - Any other class is reduced to its simple (unqualified) name and converted
+ * to kebab-case.
+ *
+ * @example
+ * resolveFieldType("java.lang.String") // → "text"
+ * resolveFieldType("java.time.LocalDate") // → "date"
+ * resolveFieldType("mylibrary.enums.StockStatus") // → "stock-status"
+ * resolveFieldType(undefined) // → "text"
+ */
+export function resolveFieldType(fieldClass: string | undefined): string {
+ if (!fieldClass) return "text";
+
+ const known = FIELD_CLASS_TYPE_MAP[fieldClass];
+ if (known) return known;
+
+ // Fall back to the simple (unqualified) class name in kebab-case
+ const simpleName = fieldClass.includes(".")
+ ? fieldClass.substring(fieldClass.lastIndexOf(".") + 1)
+ : fieldClass;
+
+ return toKebabCase(simpleName);
+}
+
+/**
+ * Convenience overload: resolves the type token directly from a `ViewField`.
+ *
+ * @example
+ * resolveViewFieldType(field) // delegates to resolveFieldType(field.fieldClass)
+ */
+export function resolveViewFieldType(field: ViewField): string {
+ return resolveFieldType(field.fieldClass);
+}
+
+/**
+ * Resolve enum constants for enum fields
+ * @param field
+ */
+export function resolveFieldEnumConstants(field: ViewField): string[] {
+ if (!field.enum) return [];
+
+ const raw = field.params?.['ENUM_CONSTANTS'];
+ if (raw == null) return [];
+
+ if (Array.isArray(raw)) {
+ return raw.map(item => String(item));
+ }
+
+ if (typeof raw === 'string') {
+ // Support comma-separated lists as well as single values
+ return raw.includes(',')
+ ? raw.split(',').map(s => s.trim()).filter(Boolean)
+ : [raw];
+ }
+
+ return [];
}
diff --git a/platform/packages/sdk/src/reports/types.ts b/platform/packages/sdk/src/reports/types.ts
deleted file mode 100644
index 4b7d5804..00000000
--- a/platform/packages/sdk/src/reports/types.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// ─── Reports types mirroring the Dynamia Platform Java model ────────────────
-
-export interface ReportDTO {
- id: string;
- name: string;
- group: string;
- endpoint: string;
- description?: string;
-}
-
-export interface ReportFilter {
- name: string;
- value: string;
-}
-
-export interface ReportFilters {
- filters: ReportFilter[];
-}
diff --git a/platform/packages/sdk/src/saas/types.ts b/platform/packages/sdk/src/saas/types.ts
deleted file mode 100644
index 6047a93a..00000000
--- a/platform/packages/sdk/src/saas/types.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-// ─── SaaS types mirroring the Dynamia Platform Java model ───────────────────
-
-export interface AccountDTO {
- id: number;
- uuid: string;
- name: string;
- status: string;
- statusDescription?: string;
- subdomain?: string;
-}
diff --git a/platform/packages/sdk/src/types.ts b/platform/packages/sdk/src/types.ts
index 317f285b..f51e0655 100644
--- a/platform/packages/sdk/src/types.ts
+++ b/platform/packages/sdk/src/types.ts
@@ -11,6 +11,13 @@ export interface DynamiaClientConfig {
password?: string;
/** Forward cookies on cross-origin requests */
withCredentials?: boolean;
+ /**
+ * Fetch CORS mode.
+ * - `'cors'` (default) – normal cross-origin requests with CORS headers.
+ * - `'no-cors'` – skips CORS preflight; response will be opaque (no body access).
+ * - `'same-origin'` – only allow same-origin requests.
+ */
+ corsMode?: RequestMode;
/** Custom fetch implementation (useful for Node.js or tests) */
fetch?: typeof fetch;
}
diff --git a/platform/packages/sdk/test/client.test.ts b/platform/packages/sdk/test/client.test.ts
index 7b7c6706..6d206ae8 100644
--- a/platform/packages/sdk/test/client.test.ts
+++ b/platform/packages/sdk/test/client.test.ts
@@ -8,9 +8,6 @@ describe('DynamiaClient', () => {
const client = makeClient(mockFetch(200, {}));
expect(client.metadata).toBeDefined();
expect(client.actions).toBeDefined();
- expect(client.reports).toBeDefined();
- expect(client.files).toBeDefined();
- expect(client.saas).toBeDefined();
expect(client.schedule).toBeDefined();
});
it('crud() returns a CrudResourceApi', () => {
diff --git a/platform/packages/sdk/test/files/files.test.ts b/platform/packages/sdk/test/files/files.test.ts
deleted file mode 100644
index 796a58e5..00000000
--- a/platform/packages/sdk/test/files/files.test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { mockFetch, makeClient } from '../helpers.js';
-
-describe('FilesApi', () => {
- it('getUrl() returns a URL with uuid query param', () => {
- const client = makeClient(mockFetch(200, {}));
- const url = client.files.getUrl('photo.png', 'uuid-123');
- expect(url).toContain('/storage/photo.png');
- expect(url).toContain('uuid=uuid-123');
- });
-});
diff --git a/platform/packages/sdk/test/metadata/actions.test.ts b/platform/packages/sdk/test/metadata/actions.test.ts
index eb26003b..13e76929 100644
--- a/platform/packages/sdk/test/metadata/actions.test.ts
+++ b/platform/packages/sdk/test/metadata/actions.test.ts
@@ -3,11 +3,59 @@ import { mockFetch, makeClient } from '../helpers.js';
describe('ActionsApi', () => {
it('executeGlobal() calls POST /api/app/metadata/actions/{action}', async () => {
- const fetchMock = mockFetch(200, { message: 'ok', status: 'SUCCESS', code: 200 });
+ const fetchMock = mockFetch(200, { data: null, status: 'SUCCESS', statusCode: 200 });
const client = makeClient(fetchMock);
await client.actions.executeGlobal('sendEmail', { params: { to: 'a@b.com' } });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/app/metadata/actions/sendEmail');
expect(init.method).toBe('POST');
});
+
+ it('execute() uses the global endpoint for plain Action metadata', async () => {
+ const fetchMock = mockFetch(200, { data: null, status: 'SUCCESS', statusCode: 200 });
+ const client = makeClient(fetchMock);
+
+ await client.actions.execute({
+ id: 'ExportDataAction',
+ name: 'Export',
+ type: 'Action',
+ });
+
+ const [url] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toContain('/api/app/metadata/actions/ExportDataAction');
+ });
+
+ it('execute() uses the entity endpoint for CrudAction metadata', async () => {
+ const fetchMock = mockFetch(200, { data: null, status: 'SUCCESS', statusCode: 200 });
+ const client = makeClient(fetchMock);
+
+ await client.actions.execute(
+ {
+ id: 'SaveAction',
+ name: 'Save',
+ type: 'CrudAction',
+ applicableClasses: ['mybookstore.domain.Book'],
+ applicableStates: ['CREATE', 'UPDATE'],
+ },
+ { dataType: 'mybookstore.domain.Book', data: { id: 1, name: 'Clean Code' } },
+ );
+
+ const [url] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toContain('/api/app/metadata/entities/mybookstore.domain.Book/action/SaveAction');
+ });
+
+ it('execute() throws when entity-scoped metadata has no resolvable class name', async () => {
+ const fetchMock = mockFetch(200, { data: null, status: 'SUCCESS', statusCode: 200 });
+ const client = makeClient(fetchMock);
+
+ await expect(async () => {
+ await client.actions.execute({
+ id: 'DeleteAction',
+ name: 'Delete',
+ type: 'CrudAction',
+ applicableClasses: ['mybookstore.domain.Book', 'mybookstore.domain.Author'],
+ applicableStates: ['READ'],
+ });
+ }).rejects.toThrow(/requires an entity class name/i);
+ });
});
diff --git a/platform/packages/sdk/test/metadata/metadata.test.ts b/platform/packages/sdk/test/metadata/metadata.test.ts
index acb5d8bd..5109a72a 100644
--- a/platform/packages/sdk/test/metadata/metadata.test.ts
+++ b/platform/packages/sdk/test/metadata/metadata.test.ts
@@ -19,12 +19,12 @@ describe('MetadataApi', () => {
fetchMock.mockResolvedValue({
ok: true, status: 200,
headers: { get: () => 'application/json' },
- json: () => Promise.resolve({ modules: [] }),
+ json: () => Promise.resolve({ navigation: [] }),
} as unknown as Response);
const result = await client.metadata.getNavigation();
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toContain('/api/app/metadata/navigation');
- expect(result.modules).toEqual([]);
+ expect(result.navigation).toEqual([]);
});
it('getEntity(className) encodes the class name in the URL', async () => {
await client.metadata.getEntity('com.example.Book');
diff --git a/platform/packages/sdk/tsconfig.build.json b/platform/packages/sdk/tsconfig.build.json
index 2507d164..aa25e2d1 100644
--- a/platform/packages/sdk/tsconfig.build.json
+++ b/platform/packages/sdk/tsconfig.build.json
@@ -1,5 +1,5 @@
{
- "extends": "../tsconfig.base.json",
+ "extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
diff --git a/platform/packages/sdk/tsconfig.json b/platform/packages/sdk/tsconfig.json
index 0bc42491..aa1f4d86 100644
--- a/platform/packages/sdk/tsconfig.json
+++ b/platform/packages/sdk/tsconfig.json
@@ -1,6 +1,7 @@
{
- "extends": "../tsconfig.base.json",
+ "extends": "../../../tsconfig.base.json",
"compilerOptions": {
+ "customConditions": ["development"],
"noEmit": true
},
"include": ["src", "test"]
diff --git a/platform/packages/ui-core/README.md b/platform/packages/ui-core/README.md
new file mode 100644
index 00000000..bbeaa2b4
--- /dev/null
+++ b/platform/packages/ui-core/README.md
@@ -0,0 +1,639 @@
+# @dynamia-tools/ui-core
+
+> Framework-agnostic view system core for **Dynamia Platform** — zero DOM, zero Vue/React.
+
+`@dynamia-tools/ui-core` implements the `ViewType → View → ViewRenderer → Viewer` pattern that mirrors the Dynamia Platform Java backend exactly. It provides all the headless logic for form, table, CRUD, tree and entity-picker views. Any UI framework adapter (Vue, React, Angular, …) is built on top of this package.
+
+---
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Architecture](#architecture)
+- [ViewType](#viewtype)
+- [View](#view)
+ - [FormView](#formview)
+ - [TableView](#tableview)
+ - [CrudView](#crudview)
+ - [TreeView](#treeview)
+ - [ConfigView](#configview)
+ - [EntityPickerView](#entitypickerview)
+- [Viewer](#viewer)
+- [ViewRendererRegistry](#viewrendererregistry)
+- [ViewRenderer interfaces](#viewrenderer-interfaces)
+- [FieldComponent](#fieldcomponent)
+- [Resolvers](#resolvers)
+ - [FieldResolver](#fieldresolver)
+ - [LayoutEngine](#layoutengine)
+ - [ActionResolver](#actionresolver)
+- [Navigation helpers](#navigation-helpers)
+- [Utils](#utils)
+ - [Converters](#converters)
+ - [Validators](#validators)
+- [Extending with custom ViewTypes](#extending-with-custom-viewtypes)
+- [Contributing](#contributing)
+- [License](#license)
+
+---
+
+## Installation
+
+```bash
+# pnpm (recommended)
+pnpm add @dynamia-tools/ui-core @dynamia-tools/sdk
+
+# npm
+npm install @dynamia-tools/ui-core @dynamia-tools/sdk
+
+# yarn
+yarn add @dynamia-tools/ui-core @dynamia-tools/sdk
+```
+
+`@dynamia-tools/sdk` is a peer dependency — it provides the HTTP client and all server-mirroring types (`ViewDescriptor`, `EntityMetadata`, `ActionMetadata`, …).
+
+---
+
+## Architecture
+
+```
+@dynamia-tools/sdk ← HTTP client, ViewDescriptor, EntityMetadata, …
+ ↓
+@dynamia-tools/ui-core ← ViewType, View, ViewRenderer, Viewer (framework-agnostic)
+ ↓
+@dynamia-tools/vue ← VueViewer, VueFormView, composables, components
+ ↓
+@dynamia-tools/react (*) ← ReactViewer, ReactFormView, hooks, components
+```
+
+`ui-core` has zero runtime DOM or framework dependencies. It can run in Node.js, a browser, a Web Worker, or any environment.
+
+The layered design means a React or Angular adapter reuses `ui-core` 100% — only the framework-specific rendering layer changes.
+
+---
+
+## ViewType
+
+`ViewType` is an **open extension interface**, not a closed enum. Anyone can define a new view type by creating a plain object that satisfies the interface:
+
+```typescript
+import { ViewType, ViewTypes, ViewRendererRegistry } from '@dynamia-tools/ui-core';
+
+// Built-in types shipped with ui-core
+ViewTypes.Form // { name: 'form' }
+ViewTypes.Table // { name: 'table' }
+ViewTypes.Crud // { name: 'crud' }
+ViewTypes.Tree // { name: 'tree' }
+ViewTypes.Config // { name: 'config' }
+ViewTypes.EntityPicker // { name: 'entitypicker' }
+ViewTypes.EntityFilters// { name: 'entityfilters' }
+ViewTypes.Export // { name: 'export' }
+ViewTypes.Json // { name: 'json' }
+
+// Custom view type — no core modifications needed
+const KanbanViewType: ViewType = { name: 'kanban' };
+```
+
+---
+
+## View
+
+`View` is the abstract base class for all view types. It owns an event emitter, the view descriptor and entity metadata:
+
+```typescript
+import { View, ViewTypes, FormView } from '@dynamia-tools/ui-core';
+import type { ViewDescriptor } from '@dynamia-tools/sdk';
+
+// Concrete View subclass
+const descriptor: ViewDescriptor = { /* from backend */ };
+const form = new FormView(descriptor, entityMetadata);
+
+await form.initialize();
+
+// Event system
+form.on('change', ({ field, value }) => console.log(field, '=', value));
+form.on('submit', (values) => console.log('submitted', values));
+
+// Value management
+form.setValue({ title: 'Clean Code', author: 'Robert C. Martin' });
+const values = form.getValue(); // { title: '...', author: '...' }
+```
+
+### FormView
+
+Handles field resolution, grid layout, values and validation for entity forms.
+
+```typescript
+import { FormView, FieldResolver, LayoutEngine } from '@dynamia-tools/ui-core';
+
+const form = new FormView(descriptor, metadata);
+await form.initialize();
+
+// Set / get individual field values
+form.setFieldValue('title', 'Clean Code');
+form.getFieldValue('title'); // 'Clean Code'
+
+// Get all resolved fields (with component, label, span)
+form.getResolvedFields();
+
+// Get computed grid layout
+form.getLayout();
+// → { columns: 3, groups: [{ name: '', rows: [{ fields: [...] }] }], allFields: [...] }
+
+// Validate (checks required fields)
+form.validate(); // true | false
+form.getErrors(); // { fieldName: 'Error message', ... }
+
+// Submit (validates + emits 'submit')
+await form.submit();
+
+// Reset to empty state
+form.reset();
+```
+
+### TableView
+
+Manages tabular data: columns, rows, pagination, sorting, search and row selection.
+
+```typescript
+import { TableView } from '@dynamia-tools/ui-core';
+
+const table = new TableView(descriptor, metadata);
+
+// Supply a loader function (called on load/nextPage/sort/search)
+table.setLoader(async (params) => {
+ const result = await client.crud('store/books').findAll(params);
+ return {
+ rows: result.content,
+ pagination: {
+ page: result.page,
+ pageSize: result.pageSize,
+ totalSize: result.total,
+ pagesNumber: result.totalPages,
+ firstResult: (result.page - 1) * result.pageSize,
+ },
+ };
+});
+
+await table.initialize();
+await table.load();
+
+// Navigation
+await table.nextPage();
+await table.prevPage();
+
+// Sorting (toggles asc/desc)
+await table.sort('title');
+
+// Search
+await table.search('clean code');
+
+// Row selection
+table.on('select', (row) => console.log('selected:', row));
+table.selectRow(row);
+```
+
+### CrudView
+
+Orchestrates a `FormView` and a `TableView` together with a mode state machine (`list | create | edit`).
+
+```typescript
+import { CrudView } from '@dynamia-tools/ui-core';
+
+const crud = new CrudView(descriptor, metadata);
+crud.tableView.setLoader(loader);
+await crud.initialize();
+
+// Mode transitions
+crud.startCreate(); // mode → 'create', resets form
+crud.startEdit(entity); // mode → 'edit', populates form
+crud.cancelEdit(); // mode → 'list', resets form
+
+// Listen to save/delete to call your API
+crud.on('save', async ({ mode, data }) => {
+ if (mode === 'create') await client.crud('books').create(data);
+ else await client.crud('books').update(data.id, data);
+});
+crud.on('delete', async (entity) => {
+ await client.crud('books').delete(entity.id);
+});
+
+// Trigger save (validates + emits 'save' + refreshes table)
+await crud.save();
+
+// Trigger delete
+await crud.delete(entity);
+```
+
+### TreeView
+
+Hierarchical tree with expand/collapse and node selection.
+
+```typescript
+import { TreeView } from '@dynamia-tools/ui-core';
+
+const tree = new TreeView(descriptor);
+await tree.initialize();
+
+tree.setSource(nodes); // TreeNode[]
+tree.expand(node);
+tree.collapse(node);
+tree.selectNode(node);
+tree.on('select', (node) => console.log(node));
+```
+
+### ConfigView
+
+Module configuration parameters with load/save lifecycle.
+
+```typescript
+import { ConfigView } from '@dynamia-tools/ui-core';
+
+const config = new ConfigView(descriptor);
+config.setLoader(async () => fetchParameters());
+config.setSaver(async (values) => saveParameters(values));
+
+await config.initialize();
+await config.loadParameters();
+
+config.setParameterValue('theme', 'dark');
+await config.saveParameters();
+```
+
+### EntityPickerView
+
+Entity search and selection (used for relation pickers).
+
+```typescript
+import { EntityPickerView } from '@dynamia-tools/ui-core';
+
+const picker = new EntityPickerView(descriptor);
+picker.setSearcher(async (query) => {
+ const result = await client.crud('books').findAll({ q: query });
+ return result.content;
+});
+
+await picker.initialize();
+await picker.search('clean');
+picker.select(picker.getSearchResults()[0]);
+console.log(picker.getValue()); // selected entity
+```
+
+---
+
+## Viewer
+
+`Viewer` is the **primary abstraction** — the single entry point for rendering any view. It mirrors the Java `tools.dynamia.zk.viewers.ui.Viewer` component.
+
+Instead of instantiating `FormView`, `TableView`, or `CrudView` directly, consumers use `Viewer` and let it resolve the correct `ViewType → View → ViewRenderer` chain internally via `ViewRendererRegistry`.
+
+```typescript
+import { Viewer } from '@dynamia-tools/ui-core';
+import { DynamiaClient } from '@dynamia-tools/sdk';
+
+const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: '...' });
+
+// By view type + entity class (fetches descriptor from backend)
+const viewer = new Viewer({
+ viewType: 'form',
+ beanClass: 'com.example.Book',
+ client,
+});
+await viewer.initialize();
+
+// By pre-loaded descriptor (skips network fetch)
+const viewer2 = new Viewer({ descriptor: preloadedDescriptor });
+await viewer2.initialize();
+
+// By descriptor ID
+const viewer3 = new Viewer({ descriptorId: 'BookCustomForm', client });
+await viewer3.initialize();
+
+// Value management
+viewer.setValue({ title: 'Clean Code' });
+viewer.getValue();
+
+// Event buffering — register before initialize, applied after
+viewer.on('submit', (values) => saveToBackend(values));
+
+// Read-only mode
+viewer.setReadonly(true);
+
+// Access the resolved view instance
+const view = viewer.view; // FormView | TableView | CrudView | ...
+
+// Actions
+const actions = viewer.getActions(); // ActionMetadata[]
+```
+
+**Resolution logic:**
+
+1. If `descriptorId` set → fetch from `client.metadata`
+2. If `descriptor` set → use directly
+3. Else → fetch via `client.metadata.getEntityView(beanClass, viewType)`
+4. With resolved descriptor → `ViewRendererRegistry.createView(viewType, descriptor, metadata)`
+5. Apply `value`, `source`, `readOnly` to the view
+6. Load actions from entity metadata (if `client` + `beanClass` provided)
+7. Event listeners registered before `initialize()` are buffered and applied after
+
+---
+
+## ViewRendererRegistry
+
+Central static registry that maps `ViewType → ViewRenderer` and `ViewType → View factory`.
+
+Framework adapters call `register()` and `registerViewFactory()` during plugin installation:
+
+```typescript
+import { ViewRendererRegistry, ViewTypes } from '@dynamia-tools/ui-core';
+
+// Register a renderer (e.g. from a Vue adapter)
+ViewRendererRegistry.register(ViewTypes.Form, myVueFormRenderer);
+
+// Register a view factory
+ViewRendererRegistry.registerViewFactory(
+ ViewTypes.Form,
+ (descriptor, metadata) => new MyFormView(descriptor, metadata)
+);
+
+// Check if registered
+ViewRendererRegistry.hasRenderer(ViewTypes.Form);
+ViewRendererRegistry.hasViewFactory(ViewTypes.Form);
+
+// Retrieve renderer
+const renderer = ViewRendererRegistry.getRenderer(ViewTypes.Form);
+```
+
+---
+
+## ViewRenderer interfaces
+
+```typescript
+import type { ViewRenderer, FormRenderer, TableRenderer, CrudRenderer, FieldRenderer } from '@dynamia-tools/ui-core';
+
+// Generic interface — implement for each ViewType
+interface ViewRenderer {
+ readonly supportedViewType: ViewType;
+ render(view: TView): TOutput;
+}
+
+// Typed sub-interfaces
+interface FormRenderer extends ViewRenderer {}
+interface TableRenderer extends ViewRenderer {}
+interface CrudRenderer extends ViewRenderer {}
+interface TreeRenderer extends ViewRenderer {}
+
+// For individual field rendering
+interface FieldRenderer {
+ readonly supportedComponent: string;
+ render(field: ResolvedField, view: FormView): TOutput;
+}
+```
+
+---
+
+## FieldComponent
+
+`FieldComponent` is a **const object** (not a TypeScript enum) so external modules can extend it without modifying core. Component names match ZK component names exactly for vocabulary consistency.
+
+```typescript
+import { FieldComponents } from '@dynamia-tools/ui-core';
+
+FieldComponents.Textbox // 'textbox'
+FieldComponents.Intbox // 'intbox'
+FieldComponents.Longbox // 'longbox'
+FieldComponents.Decimalbox // 'decimalbox'
+FieldComponents.Spinner // 'spinner'
+FieldComponents.Combobox // 'combobox'
+FieldComponents.Datebox // 'datebox'
+FieldComponents.Checkbox // 'checkbox'
+FieldComponents.EntityPicker // 'entitypicker'
+FieldComponents.EntityRefPicker // 'entityrefpicker'
+FieldComponents.EntityRefLabel // 'entityreflabel'
+FieldComponents.CoolLabel // 'coollabel'
+FieldComponents.Link // 'link'
+FieldComponents.Textareabox // 'textareabox'
+// … and more
+```
+
+---
+
+## Resolvers
+
+### FieldResolver
+
+Resolves `ViewField[]` from a descriptor into `ResolvedField[]`, inferring the component type from the field's Java class name when not explicitly specified:
+
+```typescript
+import { FieldResolver } from '@dynamia-tools/ui-core';
+
+const resolved = FieldResolver.resolveFields(descriptor, metadata);
+// Each ResolvedField has:
+// resolvedComponent: 'textbox' | 'intbox' | ...
+// resolvedLabel: 'Book Title'
+// gridSpan: 2
+// resolvedVisible: true
+// resolvedRequired: false
+// group?: 'Details'
+```
+
+**Component inference rules:**
+
+| Java type | Resolved component |
+|-----------|-------------------|
+| `String` | `textbox` |
+| `Integer` / `int` | `intbox` |
+| `Long` / `long` | `longbox` |
+| `Double` / `Float` / `BigDecimal` | `decimalbox` |
+| `Boolean` / `boolean` | `checkbox` |
+| `Date` / `LocalDate` / `LocalDateTime` | `datebox` |
+| Enum subtypes | `combobox` |
+| Unknown / default | `textbox` |
+
+Override per-field with `params.component` in the descriptor YAML:
+
+```yaml
+fields:
+ description:
+ params:
+ component: textareabox
+```
+
+### LayoutEngine
+
+Computes the grid layout from a descriptor's `params.columns` and the resolved fields:
+
+```typescript
+import { LayoutEngine } from '@dynamia-tools/ui-core';
+
+const layout = LayoutEngine.computeLayout(descriptor, resolvedFields);
+// layout.columns = 3
+// layout.groups = [
+// {
+// name: '',
+// rows: [
+// { fields: [titleField, authorField, yearField] },
+// { fields: [isbnField] },
+// ]
+// },
+// { name: 'Details', rows: [...] }
+// ]
+```
+
+Fields are wrapped to the next row when their cumulative span would exceed `columns`. The `params.span` per field controls how many columns it occupies (default: 1).
+
+### ActionResolver
+
+Resolves the list of actions applicable to an entity view:
+
+```typescript
+import { ActionResolver } from '@dynamia-tools/ui-core';
+
+const actions = ActionResolver.resolveActions(entityMetadata, 'crud');
+```
+
+---
+
+## Utils
+
+### Converters
+
+Built-in converters for display formatting:
+
+```typescript
+import {
+ currencyConverter,
+ currencySimpleConverter,
+ decimalConverter,
+ dateConverter,
+ dateTimeConverter,
+ builtinConverters,
+} from '@dynamia-tools/ui-core';
+
+currencyConverter(1234.5); // '1,234.50'
+currencySimpleConverter(1234); // '1,234'
+decimalConverter(3.14159, { decimals: 2 }); // '3.14'
+dateConverter(new Date()); // locale date string
+dateTimeConverter(new Date()); // locale date-time string
+
+// All built-ins as a map
+builtinConverters['currency'](value);
+```
+
+The `Converter` function signature is:
+
+```typescript
+type Converter = (value: unknown, params?: Record) => string;
+```
+
+### Validators
+
+Built-in field value validators:
+
+```typescript
+import { requiredValidator, constraintValidator, builtinValidators } from '@dynamia-tools/ui-core';
+
+requiredValidator(''); // 'This field is required'
+requiredValidator('hello'); // null (valid)
+
+constraintValidator('abc', { pattern: '^[0-9]+$', message: 'Numbers only' });
+// 'Numbers only'
+
+constraintValidator('123', { pattern: '^[0-9]+$' });
+// null (valid)
+```
+
+The `Validator` function signature is:
+
+```typescript
+type Validator = (value: unknown, params?: Record) => string | null;
+```
+
+---
+
+## Navigation helpers
+
+`ui-core` also ships framework-agnostic helpers for app-shell navigation trees. These utilities are intentionally stateless and reusable from Vue, React, or plain TypeScript code.
+
+```typescript
+import {
+ containsPath,
+ findNodeByPath,
+ findFirstPage,
+ resolveActivePath,
+} from '@dynamia-tools/ui-core';
+import type { NavigationTree } from '@dynamia-tools/sdk';
+
+const activePath = '/pages/store/books';
+const tree: NavigationTree = await client.metadata.getNavigation();
+
+const page = findNodeByPath(tree.navigation, activePath);
+const first = findFirstPage(tree.navigation);
+const ctx = resolveActivePath(tree, activePath);
+
+ctx.module; // current module node
+ctx.group; // current page-group node (if any)
+ctx.page; // current leaf page node
+
+containsPath(tree.navigation[0], activePath); // true | false
+```
+
+These helpers power `useNavigation` in `@dynamia-tools/vue`, so the resolution logic stays centralized in one package.
+
+---
+
+## Extending with custom ViewTypes
+
+The open extension model allows any module to add new view types and renderers without modifying `ui-core`:
+
+```typescript
+import { ViewType, ViewRendererRegistry, View, ViewRenderer } from '@dynamia-tools/ui-core';
+import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk';
+
+// 1. Define your view type
+const KanbanViewType: ViewType = { name: 'kanban' };
+
+// 2. Implement a View subclass
+class KanbanView extends View {
+ constructor(d: ViewDescriptor, m: EntityMetadata | null) {
+ super(KanbanViewType, d, m);
+ }
+ async initialize() { /* fetch columns, cards, etc. */ }
+ validate() { return true; }
+}
+
+// 3. Implement a renderer (outputs whatever the framework expects)
+class VueKanbanRenderer implements ViewRenderer {
+ readonly supportedViewType = KanbanViewType;
+ render(view: KanbanView) { return MyKanbanVueComponent; }
+}
+
+// 4. Register — now works automatically
+ViewRendererRegistry.register(KanbanViewType, new VueKanbanRenderer());
+ViewRendererRegistry.registerViewFactory(KanbanViewType, (d, m) => new KanbanView(d, m));
+```
+
+---
+
+## Contributing
+
+See the monorepo [CONTRIBUTING.md](../../../CONTRIBUTING.md) for full guidelines.
+
+```bash
+# Install all workspace dependencies
+pnpm install
+
+# Build ui-core
+pnpm --filter @dynamia-tools/ui-core build
+
+# Type-check
+pnpm --filter @dynamia-tools/ui-core typecheck
+
+# Run tests
+pnpm --filter @dynamia-tools/ui-core test
+```
+
+---
+
+## License
+
+[Apache License 2.0](../../../LICENSE) — © Dynamia Soluciones IT SAS
diff --git a/platform/packages/ui-core/package.json b/platform/packages/ui-core/package.json
new file mode 100644
index 00000000..c2df2d2c
--- /dev/null
+++ b/platform/packages/ui-core/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@dynamia-tools/ui-core",
+ "version": "26.3.2",
+ "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": {
+ ".": {
+ "development": "./src/index.ts",
+ "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"
+ },
+ "dependencies": {"@dynamia-tools/sdk": "workspace:*"},
+ "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/actions/ActionRendererRegistry.ts b/platform/packages/ui-core/src/actions/ActionRendererRegistry.ts
new file mode 100644
index 00000000..8cbe47ad
--- /dev/null
+++ b/platform/packages/ui-core/src/actions/ActionRendererRegistry.ts
@@ -0,0 +1,68 @@
+import { Registry } from '../registry/Registry.js';
+
+function toKebabCase(value: string): string {
+ return value
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
+ .replace(/[._\s]+/g, '-')
+ .toLowerCase();
+}
+
+function getSimpleRendererName(value: string): string {
+ const normalized = value.trim();
+ const withoutPackage = normalized.includes('.')
+ ? normalized.substring(normalized.lastIndexOf('.') + 1)
+ : normalized;
+
+ return withoutPackage.includes('$')
+ ? withoutPackage.substring(withoutPackage.lastIndexOf('$') + 1)
+ : withoutPackage;
+}
+
+export function getActionRendererKeyCandidates(renderer?: string | null): string[] {
+ if (!renderer) return [];
+
+ const raw = renderer.trim();
+ if (!raw) return [];
+
+ const simpleName = getSimpleRendererName(raw);
+ return [...new Set([
+ raw,
+ raw.toLowerCase(),
+ simpleName,
+ simpleName.toLowerCase(),
+ toKebabCase(simpleName),
+ ])];
+}
+
+/**
+ * Registry for action renderer components.
+ * Keys are normalised using {@link getActionRendererKeyCandidates} so that
+ * fully-qualified Java class names, simple names, and kebab-case variants all
+ * resolve to the same registered renderer.
+ *
+ * Backed by the generic {@link Registry} — public static API is unchanged.
+ */
+export class ActionRendererRegistry {
+ private static readonly _registry = new Registry(getActionRendererKeyCandidates);
+
+ static register(
+ key: string,
+ renderer: TRenderer,
+ aliases: string[] = [],
+ ): void {
+ ActionRendererRegistry._registry.register(key, renderer, aliases);
+ }
+
+ static get(renderer?: string | null): TRenderer | null {
+ return ActionRendererRegistry._registry.get(renderer) as TRenderer | null;
+ }
+
+ static has(renderer?: string | null): boolean {
+ return ActionRendererRegistry._registry.has(renderer);
+ }
+
+ static clear(): void {
+ ActionRendererRegistry._registry.clear();
+ }
+}
+
diff --git a/platform/packages/ui-core/src/actions/ClientAction.ts b/platform/packages/ui-core/src/actions/ClientAction.ts
new file mode 100644
index 00000000..c3070385
--- /dev/null
+++ b/platform/packages/ui-core/src/actions/ClientAction.ts
@@ -0,0 +1,231 @@
+// ClientAction.ts — framework-agnostic client-side action system
+
+import type { ActionExecutionRequest, ActionMetadata } from '@dynamia-tools/sdk';
+import type { View } from '../view/View.js';
+import { Registry } from '../registry/Registry.js';
+
+// ── Context ───────────────────────────────────────────────────────────────────
+
+/**
+ * Runtime context passed to a {@link ClientAction} when it is executed.
+ */
+export interface ClientActionContext {
+ /** The server-side metadata that triggered this client action. */
+ action: ActionMetadata;
+ /** Execution request built from the current view context. */
+ request: ActionExecutionRequest;
+ /** The active view the action belongs to, when available. */
+ view?: View;
+}
+
+// ── Interface ─────────────────────────────────────────────────────────────────
+
+/**
+ * A **ClientAction** lives entirely in the frontend.
+ *
+ * Implement this interface to intercept an action identified by `id` (or the
+ * server-side `className`) and handle it locally — without a server round-trip.
+ *
+ * ClientActions are resolved **before** built-in CRUD actions and before remote
+ * execution, so they can override any server-side action if needed.
+ *
+ * Use `applicableClass` / `applicableState` to scope an action to a specific
+ * entity type or CRUD state, and `renderer` to customise how it is rendered.
+ *
+ * Example — scoped export action:
+ * {@code
+ * registerClientAction({
+ * id: 'ExportCsvAction',
+ * name: 'Export CSV',
+ * applicableClass: 'Book',
+ * applicableState: 'READ',
+ * execute({ request }) {
+ * downloadCsv(request.data);
+ * },
+ * });
+ * }
+ */
+export interface ClientAction {
+ /**
+ * Must match the server-side action `id` **or** `className`.
+ * Resolution is case-insensitive.
+ */
+ id: string;
+ /** Optional display name (informational). */
+ name?: string;
+ /** Optional description (informational). */
+ description?: string;
+ /** Optional icon key (informational). */
+ icon?: string;
+ /**
+ * Entity class name(s) this action applies to.
+ * Accepts a single string or an array for multiple classes.
+ * Absent / empty means the action applies to **all** classes.
+ *
+ * Example: `'Book'` or `['Book', 'Author']`
+ */
+ applicableClass?: string | string[];
+ /**
+ * CRUD state(s) in which this action is available.
+ * Accepts a single string or an array.
+ * Recognised values: `'READ'`, `'CREATE'`, `'UPDATE'`, `'DELETE'`
+ * (case-insensitive; `'list'`/`'edit'`/`'create'` aliases are also supported).
+ * Absent / empty means the action is available in **all** states.
+ *
+ * Example: `'READ'` or `['CREATE', 'UPDATE']`
+ */
+ applicableState?: string | string[];
+ /**
+ * Optional renderer key used to customise how this action is displayed.
+ * Resolved via {@link ActionRendererRegistry} — falls back to the default
+ * button renderer when absent or unregistered.
+ */
+ renderer?: string;
+ /**
+ * Execute the action.
+ * May return a `Promise` — the caller will await it before continuing.
+ */
+ execute(ctx: ClientActionContext): void | Promise;
+}
+
+// ── Applicability helpers ─────────────────────────────────────────────────────
+
+/** @internal Coerce `string | string[] | undefined` → `string[]` */
+function toArray(value?: string | string[]): string[] {
+ if (!value) return [];
+ return Array.isArray(value) ? value : [value];
+}
+
+/**
+ * Returns `true` when `action` is applicable for the given `className` and/or
+ * `state`.
+ *
+ * Rules:
+ * - If `applicableClass` is empty / absent → matches any class.
+ * - If `applicableClass` contains `"all"` (case-insensitive) → matches any class.
+ * - Class matching is done on the **simple name** (last segment after `.`).
+ * - If `applicableState` is empty / absent → matches any state.
+ * - State matching is case-insensitive; `list` is treated as `READ`,
+ * `edit` as `UPDATE`.
+ *
+ * Example:
+ * {@code
+ * isClientActionApplicable(action, 'Book', 'READ');
+ * }
+ */
+export function isClientActionApplicable(
+ action: ClientAction,
+ className?: string | null,
+ state?: string | null,
+): boolean {
+ return _classMatches(toArray(action.applicableClass), className)
+ && _stateMatches(toArray(action.applicableState), state);
+}
+
+function _simpleName(fqn: string): string {
+ const dot = fqn.lastIndexOf('.');
+ return dot >= 0 ? fqn.substring(dot + 1) : fqn;
+}
+
+function _normalizeState(s: string): string {
+ switch (s.trim().toUpperCase()) {
+ case 'LIST':
+ case 'READ': return 'READ';
+ case 'CREATE': return 'CREATE';
+ case 'EDIT':
+ case 'UPDATE': return 'UPDATE';
+ case 'DELETE': return 'DELETE';
+ default: return s.trim().toUpperCase();
+ }
+}
+
+function _classMatches(applicableClasses: string[], className?: string | null): boolean {
+ if (applicableClasses.length === 0) return true;
+ if (applicableClasses.some(c => c.toLowerCase() === 'all')) return true;
+ if (!className) return false;
+ const targetSimple = _simpleName(className.trim()).toLowerCase();
+ return applicableClasses.some(c => {
+ const cLower = c.trim().toLowerCase();
+ return cLower === targetSimple || cLower === className.trim().toLowerCase();
+ });
+}
+
+function _stateMatches(applicableStates: string[], state?: string | null): boolean {
+ if (applicableStates.length === 0) return true;
+ if (!state) return false;
+ const normalizedTarget = _normalizeState(state);
+ return applicableStates.some(s => _normalizeState(s) === normalizedTarget);
+}
+
+// ── Key normalisation ─────────────────────────────────────────────────────────
+
+function normalizeClientActionKey(key: string): string[] {
+ const trimmed = key.trim();
+ const lower = trimmed.toLowerCase();
+ return [...new Set([trimmed, lower])];
+}
+
+// ── Registry class ────────────────────────────────────────────────────────────
+
+/**
+ * Registry for {@link ClientAction} instances.
+ *
+ * Lookup is case-insensitive and tries both `action.id` and `action.className`
+ * from the server-side {@link ActionMetadata}.
+ *
+ * Use {@link filter} (inherited from {@link Registry}) for predicate-based
+ * searches, or the convenience method {@link findApplicable} to filter by
+ * entity class and CRUD state.
+ */
+export class ClientActionRegistryClass extends Registry {
+ constructor() {
+ super(normalizeClientActionKey);
+ }
+
+ /**
+ * Resolve a {@link ClientAction} for the given {@link ActionMetadata}.
+ * Tries `action.id` first, then `action.className`.
+ * Returns `null` when no client action is registered for either.
+ */
+ resolve(action: ActionMetadata): ClientAction | null {
+ return this.get(action.id) ?? this.get(action.className ?? null);
+ }
+
+ /**
+ * Find all registered {@link ClientAction}s applicable to the given entity
+ * class and optional CRUD state.
+ *
+ * Example — get all READ actions for Book:
+ * {@code
+ * const actions = ClientActionRegistry.findApplicable('Book', 'READ');
+ * }
+ */
+ findApplicable(className?: string | null, state?: string | null): ClientAction[] {
+ return this.filter(action => isClientActionApplicable(action, className, state));
+ }
+}
+
+// ── Singleton + convenience API ───────────────────────────────────────────────
+
+/**
+ * Global singleton registry for {@link ClientAction} instances.
+ *
+ * Use {@link registerClientAction} as the primary convenience API.
+ */
+export const ClientActionRegistry = new ClientActionRegistryClass();
+
+/**
+ * Register a {@link ClientAction} in the global {@link ClientActionRegistry}.
+ *
+ * Example:
+ * {@code
+ * registerClientAction({
+ * id: 'PrintAction',
+ * applicableClass: 'Book',
+ * execute() { window.print(); },
+ * });
+ * }
+ */
+export function registerClientAction(action: ClientAction): void {
+ ClientActionRegistry.register(action.id, action);
+}
diff --git a/platform/packages/ui-core/src/actions/crudActionState.ts b/platform/packages/ui-core/src/actions/crudActionState.ts
new file mode 100644
index 00000000..21f8da64
--- /dev/null
+++ b/platform/packages/ui-core/src/actions/crudActionState.ts
@@ -0,0 +1,50 @@
+import type { CrudMode } from '../types/state.js';
+
+export type CrudActionState = 'READ' | 'CREATE' | 'UPDATE' | 'DELETE';
+export type CrudActionStateAlias = CrudMode | CrudActionState | Lowercase;
+
+const CRUD_MODE_TO_ACTION_STATE: Record = {
+ list: 'READ',
+ create: 'CREATE',
+ edit: 'UPDATE',
+};
+
+export function crudModeToActionState(mode: CrudMode): CrudActionState {
+ return CRUD_MODE_TO_ACTION_STATE[mode];
+}
+
+export function normalizeCrudActionState(state?: CrudActionStateAlias | null): CrudActionState | null {
+ if (!state) return null;
+
+ switch (String(state).trim().toUpperCase()) {
+ case 'LIST':
+ case 'READ':
+ return 'READ';
+ case 'CREATE':
+ return 'CREATE';
+ case 'EDIT':
+ case 'UPDATE':
+ return 'UPDATE';
+ case 'DELETE':
+ return 'DELETE';
+ default:
+ return null;
+ }
+}
+
+export function isCrudActionStateApplicable(
+ currentState?: CrudActionStateAlias | null,
+ applicableStates?: readonly string[] | null,
+): boolean {
+ if (!applicableStates || applicableStates.length === 0) {
+ return true;
+ }
+
+ const normalizedCurrentState = normalizeCrudActionState(currentState);
+ if (!normalizedCurrentState) {
+ return false;
+ }
+
+ return applicableStates.some(state => normalizeCrudActionState(state as CrudActionStateAlias) === normalizedCurrentState);
+}
+
diff --git a/platform/packages/ui-core/src/actions/types.ts b/platform/packages/ui-core/src/actions/types.ts
new file mode 100644
index 00000000..ea1463c7
--- /dev/null
+++ b/platform/packages/ui-core/src/actions/types.ts
@@ -0,0 +1,19 @@
+import type { ActionExecutionRequest, ActionExecutionResponse, ActionMetadata } from '@dynamia-tools/sdk';
+
+export interface ActionTriggerPayload {
+ request?: Partial;
+}
+
+export interface ActionExecutionEvent {
+ action: ActionMetadata;
+ request: ActionExecutionRequest;
+ response?: ActionExecutionResponse;
+ local?: boolean;
+}
+
+export interface ActionExecutionErrorEvent {
+ action: ActionMetadata;
+ request: ActionExecutionRequest;
+ error: unknown;
+}
+
diff --git a/platform/packages/ui-core/src/index.ts b/platform/packages/ui-core/src/index.ts
new file mode 100644
index 00000000..0b684756
--- /dev/null
+++ b/platform/packages/ui-core/src/index.ts
@@ -0,0 +1,86 @@
+// @dynamia-tools/ui-core — Framework-agnostic view/viewer/renderer core for Dynamia Platform
+
+// ── Registry base ─────────────────────────────────────────────────────────────
+export { Registry } from './registry/Registry.js';
+export type { KeyNormalizer } from './registry/Registry.js';
+
+// ── 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, DataSetViewState,
+ 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 { DataSetView } from './view/DataSetView.js';
+export { DataSetViewRegistry, dataSetViewRegistry, registerDataSetView, resolveDataSetView } from './view/DataSetViewRegistry.js';
+export type { DataSetViewFactory } from './view/DataSetViewRegistry.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 type { TreeLoader } 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';
+export type { ActionResolutionContext } from './resolvers/ActionResolver.js';
+
+// ── Actions ───────────────────────────────────────────────────────────────────
+export { ActionRendererRegistry, getActionRendererKeyCandidates } from './actions/ActionRendererRegistry.js';
+export type { ActionExecutionEvent, ActionExecutionErrorEvent, ActionTriggerPayload } from './actions/types.js';
+export type { CrudActionState, CrudActionStateAlias } from './actions/crudActionState.js';
+export {
+ crudModeToActionState,
+ normalizeCrudActionState,
+ isCrudActionStateApplicable,
+} from './actions/crudActionState.js';
+
+// ── Client actions ────────────────────────────────────────────────────────────
+export { ClientActionRegistry, registerClientAction, isClientActionApplicable } from './actions/ClientAction.js';
+export type { ClientAction, ClientActionContext, ClientActionRegistryClass } from './actions/ClientAction.js';
+
+// ── Utils ─────────────────────────────────────────────────────────────────────
+export {
+ currencyConverter, currencySimpleConverter, decimalConverter, dateConverter, dateTimeConverter,
+ builtinConverters,
+} from './utils/converters.js';
+export { requiredValidator, constraintValidator, builtinValidators } from './utils/validators.js';
+
+// ── Page resolvers ────────────────────────────────────────────────────────────
+export { CrudPageResolver, NavigationPageTypes } from './page/CrudPageResolver.js';
+export type { CrudPageContext, NavigationPageType } from './page/CrudPageResolver.js';
+
+// ── Navigation resolvers ──────────────────────────────────────────────────────
+export {
+ containsPath,
+ findNodeByPath,
+ findFirstPage,
+ resolveActivePath,
+} from './navigation/NavigationResolver.js';
+export type { ActiveNavigationPath } from './navigation/NavigationResolver.js';
+
diff --git a/platform/packages/ui-core/src/navigation/NavigationResolver.ts b/platform/packages/ui-core/src/navigation/NavigationResolver.ts
new file mode 100644
index 00000000..58d3d940
--- /dev/null
+++ b/platform/packages/ui-core/src/navigation/NavigationResolver.ts
@@ -0,0 +1,58 @@
+// NavigationResolver: framework-agnostic helpers for resolving app navigation nodes
+
+import type { NavigationTree, NavigationNode } from '@dynamia-tools/sdk';
+
+export interface ActiveNavigationPath {
+ module: NavigationNode | null;
+ group: NavigationNode | null;
+ page: NavigationNode | null;
+}
+
+/** Returns true if the node or any of its descendants has the given internal path. */
+export function containsPath(node: NavigationNode, path: string): boolean {
+ if (node.internalPath === path) return true;
+ return node.children?.some((child) => containsPath(child, path)) ?? false;
+}
+
+/** Recursively finds a node by its internal path. */
+export function findNodeByPath(nodes: NavigationNode[], path: string): NavigationNode | null {
+ for (const node of nodes) {
+ if (node.internalPath === path) return node;
+ if (!node.children?.length) continue;
+ const found = findNodeByPath(node.children, path);
+ if (found) return found;
+ }
+ return null;
+}
+
+/** Returns the first leaf page with an internal path in depth-first order. */
+export function findFirstPage(nodes: NavigationNode[]): NavigationNode | null {
+ for (const node of nodes) {
+ if (node.children?.length) {
+ const nested = findFirstPage(node.children);
+ if (nested) return nested;
+ continue;
+ }
+
+ if (node.internalPath) return node;
+ }
+
+ return null;
+}
+
+/** Resolves current module, group and page nodes for an active navigation path. */
+export function resolveActivePath(
+ tree: NavigationTree | null,
+ path: string | null,
+): ActiveNavigationPath {
+ if (!tree || !path) {
+ return { module: null, group: null, page: null };
+ }
+
+ const module = tree.navigation.find((root) => containsPath(root, path)) ?? null;
+ const group = module?.children?.find((child) => containsPath(child, path)) ?? null;
+ const page = findNodeByPath(tree.navigation, path);
+
+ return { module, group, page };
+}
+
diff --git a/platform/packages/ui-core/src/page/CrudPageResolver.ts b/platform/packages/ui-core/src/page/CrudPageResolver.ts
new file mode 100644
index 00000000..d9a957e9
--- /dev/null
+++ b/platform/packages/ui-core/src/page/CrudPageResolver.ts
@@ -0,0 +1,159 @@
+// CrudPageResolver: resolves a NavigationNode of type "CrudPage" into entity metadata and view descriptor
+
+import type {DynamiaClient, EntityMetadata, NavigationNode, ViewDescriptor} from '@dynamia-tools/sdk';
+
+// ── Known navigation page types ───────────────────────────────────────────────
+
+/**
+ * Known navigation page-type identifiers returned by the Dynamia Platform server.
+ * The `type` field on a {@link NavigationNode} is one of these values (or any custom string
+ * defined server-side).
+ */
+export const NavigationPageTypes = {
+ /** Top-level application module */
+ Module: 'Module',
+ /** Group of pages within a module */
+ PageGroup: 'PageGroup',
+ /** Generic leaf page (iframe / ZUL / etc.) */
+ Page: 'Page',
+ /**
+ * Leaf page that is automatically backed by a full CRUD interface for a single entity.
+ * The `file` field contains the fully-qualified Java class name of the entity.
+ */
+ CrudPage: 'CrudPage',
+ /**
+ * Leaf page that renders a configuration panel.
+ * The `file` field contains the configuration bean name.
+ */
+ ConfigPage: 'ConfigPage',
+ /** Leaf page rendered in an external iframe (full URL in `file`). */
+ ExternalPage: 'ExternalPage',
+} as const;
+
+export type NavigationPageType =
+ | (typeof NavigationPageTypes)[keyof typeof NavigationPageTypes]
+ | string;
+
+// ── CrudPageContext ───────────────────────────────────────────────────────────
+
+/**
+ * Fully-resolved data required to render a CrudPage.
+ * Produced by {@link CrudPageResolver.resolve}.
+ */
+export interface CrudPageContext {
+ /** The original NavigationNode */
+ node: NavigationNode;
+ /** Fully-qualified Java class name taken from {@link NavigationNode.file} */
+ entityClass: string;
+ /**ie
+ * Virtual path taken from {@link NavigationNode.internalPath}.
+ * Used as the base path for the CRUD resource API (`/api/{virtualPath}`).
+ */
+ virtualPath: string;
+ /** Entity metadata loaded from the backend */
+ entityMetadata: EntityMetadata;
+ /** View descriptor resolved for the CRUD view (crud / table type) */
+ descriptor: ViewDescriptor;
+ /** Descriptor used by the DataSetView (table/tree), when available. */
+ dataSetDescriptor: ViewDescriptor;
+ /**
+ * The dedicated form view descriptor (`view === "form"`), when available.
+ * Used to build the FormView with its own fields, layout (columns) and fieldGroups.
+ * Falls back to {@link descriptor} when no separate form descriptor exists.
+ */
+ formDescriptor: ViewDescriptor;
+}
+
+// ── CrudPageResolver ──────────────────────────────────────────────────────────
+
+/**
+ * Framework-agnostic utility that resolves a {@link NavigationNode} of type
+ * `"CrudPage"` into the entity metadata and view descriptor needed to render a
+ * full CRUD interface.
+ *
+ * Intended to be used by framework-specific composables (e.g. `useCrudPage` in the
+ * `@dynamia-tools/vue` package).
+ *
+ * Example:
+ * {@code
+ * if (CrudPageResolver.isCrudPage(node)) {
+ * const ctx = await CrudPageResolver.resolve(node, client);
+ * // ctx.descriptor, ctx.entityMetadata, ctx.virtualPath …
+ * }
+ * }
+ */
+export class CrudPageResolver {
+ /**
+ * Returns `true` when the given node has `type === "CrudPage"`.
+ */
+ static isCrudPage(node: NavigationNode): boolean {
+ return node.type === NavigationPageTypes.CrudPage;
+ }
+
+ /**
+ * Resolves a `CrudPage` navigation node into its entity metadata and view descriptor.
+ *
+ * - Fetches entity metadata via `client.metadata.getEntity(node.file)`
+ * - Fetches all view descriptors via `client.metadata.getEntityViews(node.file)` and
+ * picks the first descriptor whose view name contains `"crud"` (case-insensitive),
+ * falling back to the first available descriptor.
+ *
+ * @param node - NavigationNode with `type === "CrudPage"`, `file` and `internalPath` set.
+ * @param client - {@link DynamiaClient} used to fetch metadata from the backend.
+ * @throws {Error} when `node.file` or `node.internalPath` is missing, or no descriptor exists.
+ */
+ static async resolve(node: NavigationNode, client: DynamiaClient): Promise {
+ if (!node.file) {
+ throw new Error(`CrudPage node "${node.id}" is missing the "file" field (entity class name)`);
+ }
+ if (!node.internalPath) {
+ throw new Error(`CrudPage node "${node.id}" is missing "internalPath"`);
+ }
+
+ const entityClass = node.file;
+ const virtualPath = node.internalPath;
+
+ // Fetch entity metadata and all view descriptors in parallel
+ const [entityMetadata, descriptors] = await Promise.all([
+ client.metadata.getEntity(entityClass),
+ client.metadata.getEntityViews(entityClass),
+ ]);
+
+ // Prefer a descriptor explicitly typed "crud"; fall back to first available
+ const chosen =
+ descriptors.find(d => d.view?.toLowerCase().includes('crud')) ??
+ descriptors[0];
+
+ if (!chosen) {
+ throw new Error(`No view descriptor found for entity "${entityClass}"`);
+ }
+
+ // Resolve the dedicated form descriptor (fields + layout + fieldGroups).
+ // ZK CrudView does the same: it independently fetches the "form" descriptor
+ // for its FormView while the outer CrudView uses the "crud" descriptor.
+ const formDescriptor =
+ descriptors.find(d => d.view?.toLowerCase() === 'form') ??
+ chosen;
+
+ const dataSetViewType = String(chosen.params?.['dataSetViewType'] ?? 'table').toLowerCase();
+ const dataSetDescriptor =
+ (dataSetViewType === 'tree'
+ ? descriptors.find(d => d.view?.toLowerCase() === 'tree' || d.view?.toLowerCase().includes('tree'))
+ : descriptors.find(d => d.view?.toLowerCase() === 'table' || d.view?.toLowerCase().includes('table'))
+ )
+ // Fallback for legacy descriptors where "table" view is missing but fields are present
+ ?? descriptors.find(d => (d.fields?.length ?? 0) > 0 && d.view?.toLowerCase() !== 'form')
+ ?? chosen;
+
+ return {
+ node,
+ entityClass,
+ virtualPath,
+ entityMetadata,
+ descriptor: chosen,
+ dataSetDescriptor,
+ formDescriptor,
+ };
+ }
+}
+
diff --git a/platform/packages/ui-core/src/registry/Registry.ts b/platform/packages/ui-core/src/registry/Registry.ts
new file mode 100644
index 00000000..1051c098
--- /dev/null
+++ b/platform/packages/ui-core/src/registry/Registry.ts
@@ -0,0 +1,97 @@
+// Registry.ts — generic key→value registry with optional key normalisation
+
+/**
+ * Optional key normaliser. Given a raw key it returns the ordered list of
+ * candidate strings to try when storing or looking up a value.
+ *
+ * If not supplied, every key is used as-is (exact match only).
+ */
+export type KeyNormalizer = (key: string) => string[];
+
+/**
+ * Lightweight, framework-agnostic generic key → value registry.
+ *
+ * Subclasses (or callers) can supply a {@link KeyNormalizer} to support
+ * case-folding, aliases, package-stripped lookups, kebab-case variants, etc.
+ * — all without duplicating the bookkeeping logic.
+ *
+ * Example — case-insensitive registry:
+ * {@code
+ * const reg = new Registry<() => void>(k => [k, k.toLowerCase()]);
+ * reg.register('MyHandler', fn);
+ * reg.get('myhandler'); // → fn
+ * }
+ */
+export class Registry {
+ private readonly _map = new Map();
+ protected readonly _normalize: KeyNormalizer;
+
+ constructor(normalizer?: KeyNormalizer) {
+ this._normalize = normalizer ?? (k => [k]);
+ }
+
+ /**
+ * Register `value` under `key` and any optional `aliases`.
+ * All keys are normalised before storage.
+ */
+ register(key: string, value: TValue, aliases: string[] = []): void {
+ for (const candidate of [key, ...aliases]) {
+ for (const normalized of this._normalize(candidate)) {
+ this._map.set(normalized, value);
+ }
+ }
+ }
+
+ /**
+ * Retrieve the value registered under `key`.
+ * Returns `null` when nothing is found.
+ */
+ get(key?: string | null): TValue | null {
+ if (!key) return null;
+ for (const normalized of this._normalize(key)) {
+ const value = this._map.get(normalized);
+ if (value !== undefined) return value;
+ }
+ return null;
+ }
+
+ /** Returns `true` when a value is registered for `key`. */
+ has(key?: string | null): boolean {
+ return this.get(key) != null;
+ }
+
+ /** Remove all registered entries. */
+ clear(): void {
+ this._map.clear();
+ }
+
+ /** Number of raw stored keys (including alias entries). */
+ get size(): number {
+ return this._map.size;
+ }
+
+ /**
+ * Returns all uniquely registered values.
+ * Values stored under multiple alias / normalised keys are deduplicated by
+ * reference, so each logical entry appears exactly once.
+ */
+ values(): TValue[] {
+ return [...new Set(this._map.values())];
+ }
+
+ /**
+ * Returns all uniquely registered values that satisfy `predicate`.
+ * Each value is tested only once regardless of how many alias keys it was
+ * stored under.
+ *
+ * Example — filter ClientActions by entity class:
+ * {@code
+ * const bookActions = ClientActionRegistry.filter(
+ * a => a.applicableClass === 'Book'
+ * );
+ * }
+ */
+ filter(predicate: (value: TValue) => boolean): TValue[] {
+ return this.values().filter(predicate);
+ }
+}
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..ba04e5d0
--- /dev/null
+++ b/platform/packages/ui-core/src/resolvers/ActionResolver.ts
@@ -0,0 +1,89 @@
+// ActionResolver: resolves enabled actions for a view from entity metadata
+
+import type { EntityMetadata, ActionMetadata } from '@dynamia-tools/sdk';
+import type { CrudActionStateAlias } from '../actions/crudActionState.js';
+import { isCrudActionStateApplicable } from '../actions/crudActionState.js';
+
+export interface ActionResolutionContext {
+ /** Optional view type or rendering context. Reserved for future filtering. */
+ viewContext?: string;
+ /** Fully-qualified class name of the active entity. */
+ targetClass?: string | null;
+ /** Current CRUD action state, supporting TS aliases like list/create/edit. */
+ crudState?: CrudActionStateAlias | null;
+ /** Optional action type allow-list. */
+ actionTypes?: string[];
+}
+
+/**
+ * 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 entity metadata or a direct action array.
+ * @param metadata - Entity metadata containing action list
+ * @param context - Optional string view context or full resolution context
+ * @returns Sorted list of applicable ActionMetadata
+ */
+ static resolveActions(
+ metadata: EntityMetadata | ActionMetadata[] | null | undefined,
+ context?: string | ActionResolutionContext,
+ ): ActionMetadata[] {
+ const actions = Array.isArray(metadata) ? metadata : metadata?.actions ?? [];
+ const resolvedContext = typeof context === 'string' ? { viewContext: context } : (context ?? {});
+
+ return actions.filter(action => ActionResolver._isApplicable(action, resolvedContext));
+ }
+
+ private static _isApplicable(action: ActionMetadata, context: ActionResolutionContext): boolean {
+ return ActionResolver._matchesType(action, context)
+ && ActionResolver._matchesClass(action, context.targetClass)
+ && ActionResolver._matchesCrudState(action, context.crudState);
+ }
+
+ private static _matchesType(action: ActionMetadata, context: ActionResolutionContext): boolean {
+ if (!context.actionTypes || context.actionTypes.length === 0) {
+ return true;
+ }
+
+ return context.actionTypes.includes(action.type ?? 'Action');
+ }
+
+ private static _matchesClass(action: ActionMetadata, targetClass?: string | null): boolean {
+ const applicableClasses = action.applicableClasses ?? [];
+ if (applicableClasses.length === 0) {
+ return true;
+ }
+
+ if (applicableClasses.some(className => className.toLowerCase() === 'all')) {
+ return true;
+ }
+
+ if (!targetClass) {
+ return false;
+ }
+
+ const normalizedTargetClass = targetClass.trim();
+ const targetSimpleName = normalizedTargetClass.includes('.')
+ ? normalizedTargetClass.substring(normalizedTargetClass.lastIndexOf('.') + 1)
+ : normalizedTargetClass;
+
+ return applicableClasses.some(className => {
+ const normalizedClassName = className.trim();
+ const simpleName = normalizedClassName.includes('.')
+ ? normalizedClassName.substring(normalizedClassName.lastIndexOf('.') + 1)
+ : normalizedClassName;
+
+ return normalizedClassName === normalizedTargetClass || simpleName === targetSimpleName;
+ });
+ }
+
+ private static _matchesCrudState(action: ActionMetadata, crudState?: CrudActionStateAlias | null): boolean {
+ return isCrudActionStateApplicable(crudState, action.applicableStates);
+ }
+}
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..88123b80
--- /dev/null
+++ b/platform/packages/ui-core/src/resolvers/FieldResolver.ts
@@ -0,0 +1,118 @@
+// 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 ?? [];
+
+ // Build a reverse map: fieldName → groupName from descriptor.fieldGroups[].fields.
+ // Java serializes FieldGroup with @JsonProperty("fields") listing field names.
+ const fieldGroupMap = new Map();
+ if (descriptor.fieldGroups?.length) {
+ for (const fg of descriptor.fieldGroups) {
+ if (fg.fields?.length) {
+ for (const fieldName of fg.fields) {
+ fieldGroupMap.set(fieldName, fg.name);
+ }
+ }
+ }
+ }
+
+ return fields
+ .filter(f => f.visible !== false)
+ .map((field, index) => FieldResolver.resolveField(field, index, metadata, fieldGroupMap));
+ }
+
+ /**
+ * Resolve a single field descriptor.
+ * @param field - The raw field descriptor
+ * @param index - Field index for layout positioning
+ * @param metadata - Optional entity metadata
+ * @param fieldGroupMap - Optional reverse map: fieldName → groupName built from descriptor.fieldGroups
+ * @returns A fully resolved field
+ */
+ static resolveField(field: ViewField, index: number, _metadata: EntityMetadata | null, fieldGroupMap?: Map): ResolvedField {
+ const params = field.params ?? {};
+ const component = FieldResolver._resolveComponent(field, params);
+ const label = FieldResolver._resolveLabel(field);
+ const span = FieldResolver._resolveSpan(params);
+
+ // Group: 1) fieldGroups reverse map (Java serialized), 2) field.params.group (legacy/manual)
+ const group = fieldGroupMap?.get(field.name)
+ ?? (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. Direct component property on the field (mapped from Java Field.component)
+ if (field.component) return field.component;
+
+ // 2. Explicit component in params (legacy / override)
+ const explicitComponent = params['component'];
+ if (typeof explicitComponent === 'string' && explicitComponent) return explicitComponent;
+
+ // 3. 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..6022e776
--- /dev/null
+++ b/platform/packages/ui-core/src/resolvers/LayoutEngine.ts
@@ -0,0 +1,159 @@
+// 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 {
+ // columns lives in ViewLayout.params (descriptor.layout.params.columns),
+ // NOT in the top-level descriptor.params
+ const layoutParams = descriptor.layout?.params ?? {};
+ const columns = LayoutEngine._resolveColumns(layoutParams);
+
+ // Pre-collect which fields belong to each group (preserving flat-list order within each group)
+ const groupedFields = new Map();
+ for (const field of fields) {
+ const key = field.group ?? '';
+ if (!groupedFields.has(key)) groupedFields.set(key, []);
+ groupedFields.get(key)!.push(field);
+ }
+
+ // Build sections by scanning fields in declaration order.
+ //
+ // Named groups are emitted at the position of their FIRST field in the flat list,
+ // so the group header appears where the first group field would have been — matching
+ // the intent expressed in the YAML `fields:` order.
+ //
+ // Ungrouped fields that appear before, between, or after named groups form separate
+ // unnamed sections (no group header rendered). This keeps declaration order intact
+ // without mixing ungrouped and grouped fields into a single blob at the top or bottom.
+ const groups: ResolvedGroup[] = [];
+ const emittedGroups = new Set();
+ let pendingDefault: ResolvedField[] = [];
+ let defaultIdx = 0;
+
+ const flushDefault = () => {
+ if (pendingDefault.length > 0) {
+ groups.push({
+ name: `__default_${defaultIdx++}`,
+ rows: LayoutEngine._buildRows(pendingDefault, columns),
+ });
+ pendingDefault = [];
+ }
+ };
+
+ for (const field of fields) {
+ const groupName = field.group;
+
+ if (groupName && !emittedGroups.has(groupName)) {
+ // First occurrence of this named group → flush accumulated ungrouped fields first,
+ // then emit the complete group (all its fields collected earlier in one section).
+ flushDefault();
+ const allGroupFields = groupedFields.get(groupName) ?? [];
+ const groupParams = LayoutEngine._findGroupParams(descriptor, groupName);
+ const resolvedGroup: ResolvedGroup = {
+ name: groupName,
+ rows: LayoutEngine._buildRows(allGroupFields, columns),
+ };
+ const label = groupParams?.label ?? groupName;
+ if (label) resolvedGroup.label = label;
+ if (groupParams?.icon) resolvedGroup.icon = groupParams.icon;
+ groups.push(resolvedGroup);
+ emittedGroups.add(groupName);
+ } else if (!groupName) {
+ // Ungrouped field — accumulate into the current default section
+ pendingDefault.push(field);
+ }
+ // Fields belonging to an already-emitted group are skipped here
+ // (they were already added via allGroupFields above)
+ }
+
+ // Flush any remaining ungrouped fields (those that appeared after all named groups)
+ flushDefault();
+
+ // Update row/col indices on the resolved fields
+ for (let gi = 0; gi < groups.length; gi++) {
+ const group = groups[gi]!;
+ for (let ri = 0; ri < group.rows.length; ri++) {
+ const row = group.rows[ri]!;
+ let colIdx = 0;
+ for (const field of row.fields) {
+ (field as ResolvedField).rowIndex = gi * 1000 + ri;
+ (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;
+
+ // 1. Use the typed fieldGroups array (from Java ViewDescriptor.getFieldGroups())
+ if (descriptor.fieldGroups?.length) {
+ const fg = descriptor.fieldGroups.find((g: { name: string }) => g.name === groupName);
+ if (fg) {
+ const result: { label?: string; icon?: string } = {};
+ if (fg.label !== undefined) result.label = fg.label;
+ if (fg.icon !== undefined) result.icon = fg.icon;
+ return result;
+ }
+ }
+
+ // 2. Fallback: legacy params['groups'] map
+ 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..ff2a883e
--- /dev/null
+++ b/platform/packages/ui-core/src/types/field.ts
@@ -0,0 +1,66 @@
+// 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. External modules should define their
+ * own component identifier constants following the same pattern.
+ */
+export const FieldComponent = {
+ Textbox: 'textbox',
+ Intbox: 'intbox',
+ Longbox: 'longbox',
+ Decimalbox: 'decimalbox',
+ Spinner: 'spinner',
+ Doublespinner: 'doublespinner',
+ Combobox: 'combobox',
+ Datebox: 'datebox',
+ Dateselector: '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..f34229cf
--- /dev/null
+++ b/platform/packages/ui-core/src/types/state.ts
@@ -0,0 +1,91 @@
+// 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;
+}
+
+/** Base state for DataSetView implementations (TableView, TreeView, etc.) */
+export interface DataSetViewState extends ViewState {
+ selectedItem: unknown | null;
+}
+
+/** 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 DataSetViewState {
+ rows: unknown[];
+ pagination: CrudPageable | null;
+ sortField: string | null;
+ sortDir: SortDirection;
+ searchQuery: string;
+}
+
+/** CRUD interaction mode */
+export type CrudMode = 'list' | 'create' | 'edit';
+
+/** State specific to CrudView */
+export interface CrudState extends ViewState {
+ mode: CrudMode;
+}
+
+/**
+ * A single node in a flat tree view.
+ * Parent-child links are expressed via `parentId`; `children` is an optional
+ * convenience for APIs that already return a nested structure.
+ */
+export interface TreeNode {
+ id: string;
+ label: string;
+ /** ID of the parent node — undefined/null for root nodes */
+ parentId?: string;
+ icon?: string;
+ /** Pre-nested children returned by the API (optional) */
+ children?: TreeNode[];
+ /** Original domain entity attached to this node */
+ data?: unknown;
+}
+
+/** State specific to TreeView */
+export interface TreeState extends DataSetViewState {
+ nodes: TreeNode[];
+ expandedNodeIds: Set;
+ /** Typed narrowing of DataSetViewState.selectedItem */
+ selectedItem: TreeNode | null;
+}
+
+/** 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..77fe3c68
--- /dev/null
+++ b/platform/packages/ui-core/src/view/CrudView.ts
@@ -0,0 +1,184 @@
+// CrudView: orchestrates FormView + DataSetView + action lifecycle
+
+import type { ActionExecutionRequest, ActionMetadata, EntityMetadata, ViewDescriptor } from '@dynamia-tools/sdk';
+import { View } from './View.js';
+import { ViewTypes } from './ViewType.js';
+import { FormView } from './FormView.js';
+import type { DataSetView } from './DataSetView.js';
+import { resolveDataSetView } from './DataSetViewRegistry.js';
+import type { TableView } from './TableView.js';
+import type { TreeView } from './TreeView.js';
+import type { CrudState, CrudMode } from '../types/state.js';
+import type { ActionResolutionContext } from '../resolvers/ActionResolver.js';
+import { ActionResolver } from '../resolvers/ActionResolver.js';
+import type { CrudActionState, CrudActionStateAlias } from '../actions/crudActionState.js';
+import { crudModeToActionState, normalizeCrudActionState } from '../actions/crudActionState.js';
+
+/**
+ * CRUD view that orchestrates a {@link FormView} and a {@link DataSetView}.
+ * The concrete DataSetView type is resolved from the descriptor param
+ * `dataSetViewType` (default: `'table'`) via {@link DataSetViewRegistry}.
+ *
+ * Use {@link tableView} / {@link treeView} to narrow the dataset view to its
+ * concrete type when needed.
+ *
+ * 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 dataSetView: DataSetView;
+
+ 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.dataSetView = resolveDataSetView(
+ String(descriptor.params['dataSetViewType'] ?? 'table'),
+ descriptor,
+ entityMetadata,
+ );
+ }
+
+ // ── Narrowing convenience getters ─────────────────────────────────────────
+
+ /** Returns the dataSetView as {@link TableView}, or `null` if it is not a table */
+ get tableView(): TableView | null {
+ return this.dataSetView.isTableView() ? (this.dataSetView as unknown as TableView) : null;
+ }
+
+ /** Returns the dataSetView as {@link TreeView}, or `null` if it is not a tree */
+ get treeView(): TreeView | null {
+ return this.dataSetView.isTreeView() ? (this.dataSetView as unknown as TreeView) : null;
+ }
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
+
+ async initialize(): Promise {
+ this.state.loading = true;
+ try {
+ await Promise.all([this.formView.initialize(), this.dataSetView.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); }
+
+ // ── Mode transitions ──────────────────────────────────────────────────────
+
+ /** Get the current CRUD mode */
+ getMode(): CrudMode { return this.state.mode; }
+
+ /** Returns the current CRUD state using Java-compatible READ/CREATE/UPDATE aliases. */
+ getCrudActionState(): CrudActionState {
+ return crudModeToActionState(this.getMode());
+ }
+
+ /** Returns the entity class name associated with this CRUD view, when known. */
+ getEntityClassName(): string | null {
+ return this.entityMetadata?.className ?? this.descriptor.beanClass ?? null;
+ }
+
+ /** Returns the data object that should be used for action execution in the given state. */
+ getActionData(state: CrudActionStateAlias = this.getCrudActionState()): unknown {
+ const normalizedState = normalizeCrudActionState(state) ?? this.getCrudActionState();
+ if (normalizedState === 'CREATE' || normalizedState === 'UPDATE') {
+ return this.formView.getValue();
+ }
+
+ return this.dataSetView.getSelected();
+ }
+
+ /** Builds an ActionExecutionRequest from the current CRUD context. */
+ buildActionExecutionRequest(
+ overrides: Partial = {},
+ state: CrudActionStateAlias = this.getCrudActionState(),
+ ): ActionExecutionRequest {
+ const data = overrides.data ?? this.getActionData(state);
+ const entityClassName = overrides.dataType ?? this.getEntityClassName() ?? undefined;
+ const record = data && typeof data === 'object' ? (data as Record) : null;
+ const dataId = overrides.dataId ?? record?.id;
+ const dataName = overrides.dataName ?? (typeof record?.name === 'string' ? record.name : undefined);
+
+ return {
+ ...(data !== undefined ? { data } : {}),
+ ...(overrides.params !== undefined ? { params: overrides.params } : {}),
+ ...(overrides.source ?? this.viewType.name ? { source: overrides.source ?? this.viewType.name } : {}),
+ ...(entityClassName !== undefined ? { dataType: entityClassName } : {}),
+ ...(dataId !== undefined ? { dataId } : {}),
+ ...(dataName !== undefined ? { dataName } : {}),
+ };
+ }
+
+ /** Resolve actions for the current CRUD state and entity class. */
+ resolveActions(
+ actions?: ActionMetadata[] | null,
+ context: Omit = {},
+ ): ActionMetadata[] {
+ return ActionResolver.resolveActions(actions ?? this.entityMetadata?.actions ?? [], {
+ ...context,
+ targetClass: this.getEntityClassName(),
+ crudState: this.getCrudActionState(),
+ });
+ }
+
+ /** 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 / create 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).
+ * Validates, emits `'save'` and transitions mode to `'list'`.
+ * Callers / event handlers are responsible for the actual API call and
+ * reloading the dataset so the reload happens *after* the persist completes.
+ */
+ async save(): Promise {
+ if (!this.formView.validate()) return;
+ const data = this.formView.getValue();
+ this.emit('save', { mode: this.state.mode, data });
+ this.state.mode = 'list';
+ this.emit('mode-change', 'list');
+ }
+
+ /**
+ * Delete an entity.
+ * Emits `'delete'`. Callers / event handlers are responsible for the actual
+ * API call and reloading the dataset after the delete completes.
+ */
+ async delete(entity: unknown): Promise {
+ this.emit('delete', entity);
+ }
+}
diff --git a/platform/packages/ui-core/src/view/DataSetView.ts b/platform/packages/ui-core/src/view/DataSetView.ts
new file mode 100644
index 00000000..7c90929e
--- /dev/null
+++ b/platform/packages/ui-core/src/view/DataSetView.ts
@@ -0,0 +1,71 @@
+// DataSetView: abstract base for views that display and manage a collection of items.
+// Mirrors tools.dynamia.viewers.DataSetView from the Java/ZK platform.
+
+import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk';
+import { View } from './View.js';
+import type { ViewType } from './ViewType.js';
+import type { DataSetViewState } from '../types/state.js';
+
+/**
+ * Abstract base class for views that display and manage a collection of data items.
+ * Mirrors {@code tools.dynamia.viewers.DataSetView}.
+ *
+ * Concrete implementations ({@link TableView}, {@link TreeView}) extend this class
+ * and are registered in {@link DataSetViewRegistry}.
+ * {@link CrudView} holds a single `dataSetView` instance selected at construction
+ * time via the registry according to the descriptor param `dataSetViewType`
+ * (default: `'table'`).
+ *
+ * Example:
+ * {@code
+ * const view: DataSetView = resolveDataSetView('table', descriptor, metadata);
+ * await view.initialize();
+ * await view.load();
+ * const selected = view.getSelected();
+ * }
+ */
+export abstract class DataSetView extends View {
+ protected override state: DataSetViewState;
+
+ constructor(viewType: ViewType, descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) {
+ super(viewType, descriptor, entityMetadata);
+ this.state = { loading: false, error: null, initialized: false, selectedItem: null };
+ }
+
+ /**
+ * Load / refresh the dataset.
+ * Implementations call their internal loader and update state.
+ */
+ abstract load(params?: Record): Promise;
+
+ /**
+ * Returns the currently selected item, or `null` if nothing is selected.
+ * Mirrors {@code DataSetView.getSelected()}.
+ */
+ abstract getSelected(): unknown;
+
+ /**
+ * Programmatically select an item.
+ * Mirrors {@code DataSetView.setSelected()}.
+ */
+ abstract setSelected(item: unknown): void;
+
+ /**
+ * Returns `true` when the dataset contains no items.
+ * Mirrors {@code DataSetView.isEmpty()}.
+ */
+ abstract isEmpty(): boolean;
+
+ // ── Narrowing helpers ──────────────────────────────────────────────────────
+ // These return false by default and are overridden in each concrete subclass.
+ // Use them to narrow a DataSetView reference without importing the subclass
+ // (which would create a circular dependency). Cast to the specific subclass
+ // after checking, or use CrudView.tableView / CrudView.treeView getters.
+
+ /** Returns `true` if this instance is a {@link TableView}. */
+ isTableView(): boolean { return false; }
+
+ /** Returns `true` if this instance is a {@link TreeView}. */
+ isTreeView(): boolean { return false; }
+}
+
diff --git a/platform/packages/ui-core/src/view/DataSetViewRegistry.ts b/platform/packages/ui-core/src/view/DataSetViewRegistry.ts
new file mode 100644
index 00000000..40cc4ed8
--- /dev/null
+++ b/platform/packages/ui-core/src/view/DataSetViewRegistry.ts
@@ -0,0 +1,81 @@
+// DataSetViewRegistry: factory registry for DataSetView implementations.
+// Mirrors the CrudDataSetViewBuilder SPI from the Java/ZK platform.
+
+import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk';
+import type { DataSetView } from './DataSetView.js';
+import { TableView } from './TableView.js';
+import { TreeView } from './TreeView.js';
+
+/** Factory function that produces a DataSetView for the given descriptor */
+export type DataSetViewFactory = (
+ descriptor: ViewDescriptor,
+ entityMetadata: EntityMetadata | null,
+) => DataSetView;
+
+/**
+ * Registry that maps a `dataSetViewType` string to a {@link DataSetViewFactory}.
+ *
+ * Built-in registrations (`'table'` and `'tree'`) are added at module load time.
+ * Third-party code can call {@link registerDataSetView} to add new types.
+ *
+ * {@link CrudView} calls {@link resolveDataSetView} in its constructor using the
+ * value of `descriptor.params['dataSetViewType']` (default `'table'`).
+ *
+ * Example — register a custom Kanban view:
+ * {@code
+ * registerDataSetView('kanban', (d, m) => new KanbanView(d, m));
+ * }
+ */
+export class DataSetViewRegistry {
+ private readonly _map = new Map();
+
+ /** Register a factory for a given type name */
+ register(type: string, factory: DataSetViewFactory): void {
+ this._map.set(type, factory);
+ }
+
+ /**
+ * Resolve and instantiate a DataSetView for the given type.
+ * @throws Error when the type has no registered factory.
+ */
+ resolve(type: string, descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null): DataSetView {
+ const factory = this._map.get(type);
+ if (!factory) {
+ const known = [...this._map.keys()].join(', ');
+ throw new Error(
+ `No DataSetView factory registered for type "${type}". Known types: ${known}`,
+ );
+ }
+ return factory(descriptor, entityMetadata);
+ }
+
+ /** Returns true when a factory is registered for the given type */
+ has(type: string): boolean { return this._map.has(type); }
+}
+
+/** Singleton registry instance used by CrudView */
+export const dataSetViewRegistry = new DataSetViewRegistry();
+
+// ── Built-in registrations ────────────────────────────────────────────────────
+dataSetViewRegistry.register('table', (d, m) => new TableView(d, m));
+dataSetViewRegistry.register('tree', (d, m) => new TreeView(d, m));
+
+// ── Convenience functions ─────────────────────────────────────────────────────
+
+/** Register a custom DataSetView factory for a given type name */
+export function registerDataSetView(type: string, factory: DataSetViewFactory): void {
+ dataSetViewRegistry.register(type, factory);
+}
+
+/**
+ * Resolve a DataSetView instance from the singleton registry.
+ * Used internally by {@link CrudView}.
+ */
+export function resolveDataSetView(
+ type: string,
+ descriptor: ViewDescriptor,
+ entityMetadata: EntityMetadata | null,
+): DataSetView {
+ return dataSetViewRegistry.resolve(type, descriptor, entityMetadata);
+}
+
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..eafe9b5c
--- /dev/null
+++ b/platform/packages/ui-core/src/view/TableView.ts
@@ -0,0 +1,167 @@
+// TableView: handles tabular data, columns, pagination, sorting and search
+
+import type { ViewDescriptor, EntityMetadata, CrudPageable } from '@dynamia-tools/sdk';
+import { DataSetView } from './DataSetView.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';
+
+/**
+ * DataSetView implementation for tabular data.
+ * 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 DataSetView {
+ protected override state: TableState;
+
+ private _resolvedColumns: ResolvedField[] = [];
+ private _readOnly = false;
+ private _crudPath?: string;
+ private _tableLoader?: (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: '',
+ selectedItem: 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; }
+
+ // ── DataSetView contract ──────────────────────────────────────────────────
+
+ override isTableView(): boolean { return true; }
+
+ override getSelected(): unknown { return this.state.selectedItem; }
+
+ override setSelected(item: unknown): void {
+ this.state.selectedItem = item;
+ this.emit('select', item);
+ }
+
+ override isEmpty(): boolean { return this.state.rows.length === 0; }
+
+ /** Load data with optional query parameters */
+ override async load(params: Record = {}): Promise {
+ this.state.loading = true;
+ this.state.error = null;
+ try {
+ if (this._tableLoader) {
+ const result = await this._tableLoader({ ...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);
+ throw e;
+ } finally {
+ this.state.loading = false;
+ }
+ }
+
+ // ── Table-specific API ────────────────────────────────────────────────────
+
+ /** 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._tableLoader = loader;
+ }
+
+ /** 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 (alias for setSelected with table semantics) */
+ selectRow(row: unknown): void { this.setSelected(row); }
+
+ /** Get selected row (alias for getSelected) */
+ getSelectedRow(): unknown { return this.getSelected(); }
+
+ /** 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..a1cc6277
--- /dev/null
+++ b/platform/packages/ui-core/src/view/TreeView.ts
@@ -0,0 +1,142 @@
+// TreeView: headless hierarchical tree DataSetView (flat-node model)
+
+import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk';
+import { DataSetView } from './DataSetView.js';
+import { ViewTypes } from './ViewType.js';
+import type { TreeState, TreeNode } from '../types/state.js';
+
+/** Loader function type for TreeView — returns a flat array of nodes */
+export type TreeLoader = (params: Record) => Promise<{ nodes: TreeNode[] }>;
+
+/**
+ * DataSetView implementation for hierarchical tree data.
+ * Stores a flat {@link TreeNode} array; parent-child links are expressed via
+ * `TreeNode.parentId`. UI components (e.g. Tree.vue) are responsible for
+ * building the visual hierarchy from the flat list.
+ *
+ * Example:
+ * {@code
+ * const view = new TreeView(descriptor, metadata);
+ * view.setLoader(async () => ({ nodes: await api.getCategories() }));
+ * await view.initialize();
+ * await view.load();
+ * view.expand(view.getNodes()[0]);
+ * }
+ */
+export class TreeView extends DataSetView {
+ protected override state: TreeState;
+
+ private _treeLoader?: TreeLoader;
+
+ constructor(descriptor: ViewDescriptor, entityMetadata: EntityMetadata | null = null) {
+ super(ViewTypes.Tree, descriptor, entityMetadata);
+ this.state = {
+ loading: false,
+ error: null,
+ initialized: false,
+ nodes: [],
+ expandedNodeIds: new Set(),
+ selectedItem: null,
+ };
+ }
+
+ async initialize(): Promise {
+ this.state.initialized = true;
+ this.emit('ready');
+ }
+
+ validate(): boolean { return true; }
+
+ // ── DataSetView contract ──────────────────────────────────────────────────
+
+ override isTreeView(): boolean { return true; }
+
+ override getSelected(): TreeNode | null { return this.state.selectedItem; }
+
+ override setSelected(item: unknown): void {
+ if (item == null) {
+ this.state.selectedItem = null;
+ this.emit('select', null);
+ return;
+ }
+ // Accept a TreeNode directly or match by id/reference
+ const node = this._findNode(item);
+ this.state.selectedItem = node ?? null;
+ this.emit('select', this.state.selectedItem);
+ }
+
+ override isEmpty(): boolean { return this.state.nodes.length === 0; }
+
+ /** Load nodes using the registered loader */
+ override async load(params: Record = {}): Promise {
+ this.state.loading = true;
+ this.state.error = null;
+ try {
+ if (this._treeLoader) {
+ const result = await this._treeLoader(params);
+ this.state.nodes = result.nodes;
+ this.emit('load', this.state.nodes);
+ }
+ } catch (e) {
+ this.state.error = String(e);
+ this.emit('error', e);
+ throw e;
+ } finally {
+ this.state.loading = false;
+ }
+ }
+
+ // ── Tree-specific API ─────────────────────────────────────────────────────
+
+ /** Set the loader function that supplies flat tree nodes */
+ setLoader(loader: TreeLoader): void { this._treeLoader = loader; }
+
+ /** Get all flat nodes */
+ getNodes(): TreeNode[] { return this.state.nodes; }
+
+ /** 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);
+ }
+
+ /** Toggle expand/collapse for a node */
+ toggle(node: TreeNode): void {
+ if (this.isExpanded(node)) {
+ this.collapse(node);
+ } else {
+ this.expand(node);
+ }
+ }
+
+ /** Select a node (preferred API for tree-specific selection) */
+ selectNode(node: TreeNode): void {
+ this.state.selectedItem = node;
+ this.emit('select', node);
+ }
+
+ /** Returns true if the given node is expanded */
+ isExpanded(node: TreeNode): boolean { return this.state.expandedNodeIds.has(node.id); }
+
+ /** Returns the ID of the selected node, or null */
+ getSelectedNodeId(): string | null { return this.state.selectedItem?.id ?? null; }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private _findNode(item: unknown): TreeNode | undefined {
+ if (typeof item === 'object' && item !== null && 'id' in item) {
+ const id = (item as TreeNode).id;
+ return this.state.nodes.find(n => n.id === id);
+ }
+ if (typeof item === 'string') {
+ return this.state.nodes.find(n => n.id === item);
+ }
+ return undefined;
+ }
+}
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..f2d1d05b
--- /dev/null
+++ b/platform/packages/ui-core/src/view/ViewType.ts
@@ -0,0 +1,34 @@
+// 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 — external modules define their own
+ * ViewType objects and register them with ViewRendererRegistry independently.
+ *
+ * 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..5ea9bebc
--- /dev/null
+++ b/platform/packages/ui-core/src/viewer/Viewer.ts
@@ -0,0 +1,275 @@
+// 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 (_e) {
+ // Ignore — actions are optional; entity metadata lookup may fail for dynamic beanClass
+ }
+ }
+
+ 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 and no descriptor: cannot proceed
+ throw new Error('Either provide a descriptor directly or a DynamiaClient to fetch it from the backend');
+ }
+ if (this.descriptorId) {
+ // Fetch by descriptor ID:
+ // ViewDescriptorMetadata.descriptor is @JsonIgnore in Java — the actual
+ // ViewDescriptor content must be fetched via the /views endpoint.
+ // We match on d.id (the ViewDescriptorMetadata lightweight id field)
+ // and then retrieve the full descriptor using getEntityView().
+ const meta = await this.client.metadata.getEntities();
+ for (const entity of meta.entities) {
+ const match = entity.descriptors.find(d => d.id === this.descriptorId);
+ if (match) {
+ if (!this.beanClass) this.beanClass = entity.className;
+ // Prefer the view name from the metadata reference; fall back to any
+ // view available for this entity (picks the first one from /views).
+ if (match.view) {
+ this._resolvedDescriptor = await this.client.metadata.getEntityView(entity.className, match.view);
+ } else {
+ const views = await this.client.metadata.getEntityViews(entity.className);
+ this._resolvedDescriptor = views[0] ?? null;
+ }
+ if (this._resolvedDescriptor) return;
+ }
+ }
+ throw new Error(`Descriptor with id '${this.descriptorId}' not found`);
+ }
+ if (this.beanClass && this.viewType) {
+ // Fetch full ViewDescriptor by beanClass + viewType via the dedicated API.
+ // ViewDescriptorMetadata only carries lightweight reference fields (id, view,
+ // device, beanClass, endpoint) — the full descriptor lives at /views/{view}.
+ const typeName = typeof this.viewType === 'string' ? this.viewType : this.viewType.name;
+ try {
+ this._resolvedDescriptor = await this.client.metadata.getEntityView(this.beanClass, typeName);
+ if (this._resolvedDescriptor) return;
+ } catch (_e) {
+ // Not found via direct fetch — fall through to minimal descriptor below
+ }
+ }
+ // 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 ?? '',
+ view: 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.view;
+ 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/test/actions/ActionResolver.test.ts b/platform/packages/ui-core/test/actions/ActionResolver.test.ts
new file mode 100644
index 00000000..f61a617a
--- /dev/null
+++ b/platform/packages/ui-core/test/actions/ActionResolver.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from 'vitest';
+import type { ActionMetadata, EntityMetadata, ViewDescriptor } from '@dynamia-tools/sdk';
+import { CrudView, ActionResolver } from '../../src/index.js';
+
+function createAction(action: Partial & Pick): ActionMetadata {
+ return {
+ description: '',
+ ...action,
+ };
+}
+
+function createMetadata(actions: ActionMetadata[]): EntityMetadata {
+ return {
+ id: 'Book',
+ name: 'Book',
+ className: 'mybookstore.domain.Book',
+ actions,
+ descriptors: [],
+ actionsEndpoint: '/api/books/actions',
+ };
+}
+
+function createDescriptor(): ViewDescriptor {
+ return {
+ id: 'book-crud',
+ beanClass: 'mybookstore.domain.Book',
+ view: 'crud',
+ fields: [],
+ params: {},
+ };
+}
+
+describe('ActionResolver', () => {
+ it('filters actions by applicable class and CRUD alias state', () => {
+ const actions = [
+ createAction({ id: 'NewAction', name: 'New', type: 'CrudAction', applicableStates: ['READ'] }),
+ createAction({ id: 'SaveAction', name: 'Save', type: 'CrudAction', applicableStates: ['CREATE', 'UPDATE'] }),
+ createAction({ id: 'AdminOnlyAction', name: 'Admin', type: 'ClassAction', applicableClasses: ['mybookstore.domain.Admin'] }),
+ ];
+
+ const resolved = ActionResolver.resolveActions(actions, {
+ targetClass: 'mybookstore.domain.Book',
+ crudState: 'list',
+ });
+
+ expect(resolved.map(action => action.id)).toEqual(['NewAction']);
+ });
+
+ it('treats "all" as a wildcard applicable class', () => {
+ const resolved = ActionResolver.resolveActions([
+ createAction({
+ id: 'ExportAction',
+ name: 'Export',
+ type: 'ClassAction',
+ applicableClasses: ['all'],
+ }),
+ ], {
+ targetClass: 'mybookstore.domain.Book',
+ crudState: 'READ',
+ });
+
+ expect(resolved).toHaveLength(1);
+ });
+});
+
+describe('CrudView action helpers', () => {
+ it('maps mode aliases to Java CRUD action states', () => {
+ const metadata = createMetadata([]);
+ const view = new CrudView(createDescriptor(), metadata);
+
+ expect(view.getCrudActionState()).toBe('READ');
+
+ view.startCreate();
+ expect(view.getCrudActionState()).toBe('CREATE');
+
+ view.startEdit({ id: 7, name: 'Domain-Driven Design' });
+ expect(view.getCrudActionState()).toBe('UPDATE');
+ });
+
+ it('builds execution requests from the active CRUD context', () => {
+ const metadata = createMetadata([]);
+ const view = new CrudView(createDescriptor(), metadata);
+
+ view.startEdit({ id: 9, name: 'Refactoring' });
+
+ expect(view.buildActionExecutionRequest()).toEqual({
+ data: { id: 9, name: 'Refactoring' },
+ source: 'crud',
+ dataType: 'mybookstore.domain.Book',
+ dataId: 9,
+ dataName: 'Refactoring',
+ });
+ });
+
+ it('resolves the current entity actions using the CRUD state alias', () => {
+ const metadata = createMetadata([
+ createAction({ id: 'NewAction', name: 'New', type: 'CrudAction', applicableStates: ['READ'] }),
+ createAction({ id: 'SaveAction', name: 'Save', type: 'CrudAction', applicableStates: ['CREATE', 'UPDATE'] }),
+ ]);
+ const view = new CrudView(createDescriptor(), metadata);
+
+ expect(view.resolveActions().map(action => action.id)).toEqual(['NewAction']);
+
+ view.startCreate();
+ expect(view.resolveActions().map(action => action.id)).toEqual(['SaveAction']);
+ });
+});
+
+
diff --git a/platform/packages/ui-core/test/actions/ClientActionRegistry.test.ts b/platform/packages/ui-core/test/actions/ClientActionRegistry.test.ts
new file mode 100644
index 00000000..42ab29a5
--- /dev/null
+++ b/platform/packages/ui-core/test/actions/ClientActionRegistry.test.ts
@@ -0,0 +1,185 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import type { ActionMetadata } from '@dynamia-tools/sdk';
+import {
+ ClientActionRegistry,
+ registerClientAction,
+ isClientActionApplicable,
+} from '../../src/actions/ClientAction.js';
+import type { ClientAction, ClientActionContext } from '../../src/actions/ClientAction.js';
+
+// Helper: minimal ActionMetadata
+function meta(id: string, className?: string): ActionMetadata {
+ return { id, name: id, ...(className ? { className } : {}) };
+}
+
+describe('ClientActionRegistry — resolution', () => {
+ beforeEach(() => ClientActionRegistry.clear());
+
+ it('resolves a registered action by id (exact)', () => {
+ registerClientAction({ id: 'ExportAction', execute: vi.fn() });
+ expect(ClientActionRegistry.resolve(meta('ExportAction'))).not.toBeNull();
+ });
+
+ it('resolves by id case-insensitively', () => {
+ registerClientAction({ id: 'ExportAction', execute: vi.fn() });
+ expect(ClientActionRegistry.resolve(meta('exportaction'))).not.toBeNull();
+ expect(ClientActionRegistry.resolve(meta('EXPORTACTION'))).not.toBeNull();
+ });
+
+ it('resolves by action.className when id does not match', () => {
+ registerClientAction({ id: 'ExportAction', execute: vi.fn() });
+ const result = ClientActionRegistry.resolve(meta('something-else', 'ExportAction'));
+ expect(result).not.toBeNull();
+ });
+
+ it('returns null for an unregistered action', () => {
+ expect(ClientActionRegistry.resolve(meta('UnknownAction'))).toBeNull();
+ });
+
+ it('calls execute with the provided context', async () => {
+ const execute = vi.fn();
+ registerClientAction({ id: 'TestAction', execute });
+ const ctx: ClientActionContext = { action: meta('TestAction'), request: { data: { id: 1 } } };
+ await ClientActionRegistry.resolve(meta('TestAction'))!.execute(ctx);
+ expect(execute).toHaveBeenCalledWith(ctx);
+ });
+
+ it('supports async execute', async () => {
+ let resolved = false;
+ registerClientAction({ id: 'AsyncAction', async execute() { await Promise.resolve(); resolved = true; } });
+ await ClientActionRegistry.resolve(meta('AsyncAction'))!.execute({ action: meta('AsyncAction'), request: {} });
+ expect(resolved).toBe(true);
+ });
+
+ it('last registration wins for the same id', () => {
+ const first = vi.fn();
+ const second = vi.fn();
+ registerClientAction({ id: 'DupAction', execute: first });
+ registerClientAction({ id: 'DupAction', execute: second });
+ expect(ClientActionRegistry.resolve(meta('DupAction'))!.execute).toBe(second);
+ });
+
+ it('clear() removes all registered actions', () => {
+ registerClientAction({ id: 'SomeAction', execute: vi.fn() });
+ ClientActionRegistry.clear();
+ expect(ClientActionRegistry.resolve(meta('SomeAction'))).toBeNull();
+ });
+
+ it('carries optional metadata fields (name, description, icon, renderer)', () => {
+ const action: ClientAction = {
+ id: 'RichAction', name: 'Rich', description: 'Desc', icon: 'star', renderer: 'custom-btn',
+ execute: vi.fn(),
+ };
+ registerClientAction(action);
+ const found = ClientActionRegistry.resolve(meta('RichAction'))!;
+ expect(found.name).toBe('Rich');
+ expect(found.description).toBe('Desc');
+ expect(found.icon).toBe('star');
+ expect(found.renderer).toBe('custom-btn');
+ });
+});
+
+// ── isClientActionApplicable ─────────────────────────────────────────────────
+
+describe('isClientActionApplicable', () => {
+ const noop = vi.fn();
+
+ it('returns true when applicableClass and applicableState are absent', () => {
+ expect(isClientActionApplicable({ id: 'A', execute: noop }, 'Book', 'READ')).toBe(true);
+ });
+
+ it('matches by simple class name (case-insensitive)', () => {
+ const a: ClientAction = { id: 'A', applicableClass: 'Book', execute: noop };
+ expect(isClientActionApplicable(a, 'mybookstore.domain.Book')).toBe(true);
+ expect(isClientActionApplicable(a, 'book')).toBe(true);
+ expect(isClientActionApplicable(a, 'Author')).toBe(false);
+ });
+
+ it('matches multiple classes (array)', () => {
+ const a: ClientAction = { id: 'A', applicableClass: ['Book', 'Author'], execute: noop };
+ expect(isClientActionApplicable(a, 'Book')).toBe(true);
+ expect(isClientActionApplicable(a, 'Author')).toBe(true);
+ expect(isClientActionApplicable(a, 'Invoice')).toBe(false);
+ });
+
+ it('"all" wildcard matches any class', () => {
+ const a: ClientAction = { id: 'A', applicableClass: 'all', execute: noop };
+ expect(isClientActionApplicable(a, 'Book')).toBe(true);
+ expect(isClientActionApplicable(a, 'Invoice')).toBe(true);
+ });
+
+ it('returns false when class required but none provided', () => {
+ const a: ClientAction = { id: 'A', applicableClass: 'Book', execute: noop };
+ expect(isClientActionApplicable(a, null)).toBe(false);
+ });
+
+ it('matches state aliases (list→READ, edit→UPDATE)', () => {
+ const a: ClientAction = { id: 'A', applicableState: 'READ', execute: noop };
+ expect(isClientActionApplicable(a, null, 'list')).toBe(true);
+ expect(isClientActionApplicable(a, null, 'READ')).toBe(true);
+ expect(isClientActionApplicable(a, null, 'CREATE')).toBe(false);
+ });
+
+ it('matches multiple states (array)', () => {
+ const a: ClientAction = { id: 'A', applicableState: ['CREATE', 'UPDATE'], execute: noop };
+ expect(isClientActionApplicable(a, null, 'create')).toBe(true);
+ expect(isClientActionApplicable(a, null, 'edit')).toBe(true);
+ expect(isClientActionApplicable(a, null, 'READ')).toBe(false);
+ });
+
+ it('returns false when state required but none provided', () => {
+ const a: ClientAction = { id: 'A', applicableState: 'READ', execute: noop };
+ expect(isClientActionApplicable(a, null, null)).toBe(false);
+ });
+
+ it('combines class AND state checks', () => {
+ const a: ClientAction = { id: 'A', applicableClass: 'Book', applicableState: 'READ', execute: noop };
+ expect(isClientActionApplicable(a, 'Book', 'READ')).toBe(true);
+ expect(isClientActionApplicable(a, 'Book', 'CREATE')).toBe(false);
+ expect(isClientActionApplicable(a, 'Author', 'READ')).toBe(false);
+ });
+});
+
+// ── findApplicable ────────────────────────────────────────────────────────────
+
+describe('ClientActionRegistry.findApplicable', () => {
+ beforeEach(() => ClientActionRegistry.clear());
+
+ it('returns all actions when no class/state given', () => {
+ registerClientAction({ id: 'A', execute: vi.fn() });
+ registerClientAction({ id: 'B', execute: vi.fn() });
+ expect(ClientActionRegistry.findApplicable()).toHaveLength(2);
+ });
+
+ it('filters by class name', () => {
+ registerClientAction({ id: 'BookExport', applicableClass: 'Book', execute: vi.fn() });
+ registerClientAction({ id: 'AuthorExport', applicableClass: 'Author', execute: vi.fn() });
+ registerClientAction({ id: 'Global', execute: vi.fn() });
+
+ const result = ClientActionRegistry.findApplicable('Book');
+ expect(result.map(a => a.id)).toEqual(expect.arrayContaining(['BookExport', 'Global']));
+ expect(result.find(a => a.id === 'AuthorExport')).toBeUndefined();
+ });
+
+ it('filters by class AND state', () => {
+ registerClientAction({ id: 'NewBook', applicableClass: 'Book', applicableState: 'READ', execute: vi.fn() });
+ registerClientAction({ id: 'SaveBook', applicableClass: 'Book', applicableState: ['CREATE', 'UPDATE'], execute: vi.fn() });
+ registerClientAction({ id: 'Global', execute: vi.fn() });
+
+ const read = ClientActionRegistry.findApplicable('Book', 'READ');
+ expect(read.map(a => a.id)).toEqual(expect.arrayContaining(['NewBook', 'Global']));
+ expect(read.find(a => a.id === 'SaveBook')).toBeUndefined();
+
+ const create = ClientActionRegistry.findApplicable('Book', 'create');
+ expect(create.map(a => a.id)).toEqual(expect.arrayContaining(['SaveBook', 'Global']));
+ expect(create.find(a => a.id === 'NewBook')).toBeUndefined();
+ });
+
+ it('uses filter() predicate directly for custom queries', () => {
+ registerClientAction({ id: 'A', renderer: 'custom', execute: vi.fn() });
+ registerClientAction({ id: 'B', execute: vi.fn() });
+ const withRenderer = ClientActionRegistry.filter(a => a.renderer === 'custom');
+ expect(withRenderer).toHaveLength(1);
+ expect(withRenderer[0].id).toBe('A');
+ });
+});
diff --git a/platform/packages/ui-core/test/navigation/NavigationResolver.test.ts b/platform/packages/ui-core/test/navigation/NavigationResolver.test.ts
new file mode 100644
index 00000000..3d38c62b
--- /dev/null
+++ b/platform/packages/ui-core/test/navigation/NavigationResolver.test.ts
@@ -0,0 +1,181 @@
+import { describe, expect, it } from 'vitest';
+import type { NavigationNode, NavigationTree } from '@dynamia-tools/sdk';
+import {
+ containsPath,
+ findFirstPage,
+ findNodeByPath,
+ resolveActivePath,
+} from '../../src';
+
+function createTree(): NavigationTree {
+ const booksCrud: NavigationNode = {
+ id: 'books-crud',
+ name: 'Books',
+ type: 'CrudPage',
+ internalPath: '/pages/store/books',
+ };
+
+ const authorsPage: NavigationNode = {
+ id: 'authors-page',
+ name: 'Authors',
+ type: 'Page',
+ internalPath: '/pages/store/authors',
+ };
+
+ const storeGroup: NavigationNode = {
+ id: 'store-group',
+ name: 'Store',
+ type: 'PageGroup',
+ children: [booksCrud, authorsPage],
+ };
+
+ const dashboardPage: NavigationNode = {
+ id: 'dashboard-page',
+ name: 'Dashboard',
+ type: 'Page',
+ internalPath: '/pages/dashboard',
+ };
+
+ const storeModule: NavigationNode = {
+ id: 'store-module',
+ name: 'Store Module',
+ type: 'Module',
+ children: [storeGroup, dashboardPage],
+ };
+
+ const adminModule: NavigationNode = {
+ id: 'admin-module',
+ name: 'Admin Module',
+ type: 'Module',
+ children: [
+ {
+ id: 'settings-page',
+ name: 'Settings',
+ type: 'Page',
+ internalPath: '/pages/admin/settings',
+ },
+ ],
+ };
+
+ return {
+ navigation: [storeModule, adminModule],
+ };
+}
+
+describe('NavigationResolver.containsPath', () => {
+ const tree = createTree();
+ const module = tree.navigation.find((node) => node.id === 'store-module');
+ if (!module) throw new Error('Test fixture is missing store-module');
+
+ const group = module.children?.find((node) => node.id === 'store-group');
+ if (!group) throw new Error('Test fixture is missing store-group');
+
+ it('returns true when path matches descendant node', () => {
+ expect(containsPath(module, '/pages/store/books')).toBe(true);
+ });
+
+ it('returns true when path matches node directly', () => {
+ expect(containsPath(group, '/pages/store/authors')).toBe(true);
+ });
+
+ it('returns false when path is not present', () => {
+ expect(containsPath(module, '/pages/unknown')).toBe(false);
+ });
+});
+
+describe('NavigationResolver.findNodeByPath', () => {
+ const tree = createTree();
+
+ it('finds nested page by internalPath', () => {
+ const node = findNodeByPath(tree.navigation, '/pages/store/books');
+ expect(node?.id).toBe('books-crud');
+ });
+
+ it('returns null when no node matches the path', () => {
+ const node = findNodeByPath(tree.navigation, '/pages/missing');
+ expect(node).toBeNull();
+ });
+});
+
+describe('NavigationResolver.findFirstPage', () => {
+ it('returns first leaf page in depth-first order', () => {
+ const tree = createTree();
+ const first = findFirstPage(tree.navigation);
+ expect(first?.internalPath).toBe('/pages/store/books');
+ });
+
+ it('returns null for an empty tree', () => {
+ expect(findFirstPage([])).toBeNull();
+ });
+
+ it('ignores nodes without internalPath', () => {
+ const tree: NavigationTree = {
+ navigation: [
+ {
+ id: 'module-only',
+ name: 'Module Only',
+ type: 'Module',
+ children: [
+ {
+ id: 'group-only',
+ name: 'Group Only',
+ type: 'PageGroup',
+ children: [
+ { id: 'no-path', name: 'No Path', type: 'Page' },
+ { id: 'with-path', name: 'With Path', type: 'Page', internalPath: '/pages/ok' },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ expect(findFirstPage(tree.navigation)?.id).toBe('with-path');
+ });
+});
+
+describe('NavigationResolver.resolveActivePath', () => {
+ const tree = createTree();
+
+ it('returns null context when tree is null', () => {
+ expect(resolveActivePath(null, '/pages/store/books')).toEqual({
+ module: null,
+ group: null,
+ page: null,
+ });
+ });
+
+ it('returns null context when path is null', () => {
+ expect(resolveActivePath(tree, null)).toEqual({
+ module: null,
+ group: null,
+ page: null,
+ });
+ });
+
+ it('resolves module, group and page for nested page path', () => {
+ const result = resolveActivePath(tree, '/pages/store/books');
+
+ expect(result.module?.id).toBe('store-module');
+ expect(result.group?.id).toBe('store-group');
+ expect(result.page?.id).toBe('books-crud');
+ });
+
+ it('returns module and uses direct child as group when page hangs from module', () => {
+ const result = resolveActivePath(tree, '/pages/dashboard');
+
+ expect(result.module?.id).toBe('store-module');
+ expect(result.group?.id).toBe('dashboard-page');
+ expect(result.page?.id).toBe('dashboard-page');
+ });
+
+ it('returns null context for unknown path', () => {
+ const result = resolveActivePath(tree, '/pages/does-not-exist');
+
+ expect(result.module).toBeNull();
+ expect(result.group).toBeNull();
+ expect(result.page).toBeNull();
+ });
+});
+
+
diff --git a/platform/packages/ui-core/test/registry/Registry.test.ts b/platform/packages/ui-core/test/registry/Registry.test.ts
new file mode 100644
index 00000000..73ab32d6
--- /dev/null
+++ b/platform/packages/ui-core/test/registry/Registry.test.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { Registry } from '../../src/registry/Registry.js';
+
+describe('Registry', () => {
+ it('stores and retrieves a value by exact key', () => {
+ const reg = new Registry();
+ reg.register('foo', 'bar');
+ expect(reg.get('foo')).toBe('bar');
+ });
+
+ it('returns null for an unknown key', () => {
+ const reg = new Registry();
+ expect(reg.get('unknown')).toBeNull();
+ });
+
+ it('returns null for null / undefined key', () => {
+ const reg = new Registry();
+ reg.register('x', 'val');
+ expect(reg.get(null)).toBeNull();
+ expect(reg.get(undefined)).toBeNull();
+ });
+
+ it('registers aliases alongside the primary key', () => {
+ const reg = new Registry();
+ reg.register('primary', 'value', ['alias1', 'alias2']);
+ expect(reg.get('primary')).toBe('value');
+ expect(reg.get('alias1')).toBe('value');
+ expect(reg.get('alias2')).toBe('value');
+ });
+
+ it('uses key normalizer during registration and lookup', () => {
+ const reg = new Registry(k => [k, k.toLowerCase()]);
+ reg.register('MyKey', 'val');
+ expect(reg.get('MyKey')).toBe('val');
+ expect(reg.get('mykey')).toBe('val');
+ });
+
+ it('has() returns true for registered keys, false otherwise', () => {
+ const reg = new Registry();
+ reg.register('x', 42);
+ expect(reg.has('x')).toBe(true);
+ expect(reg.has('z')).toBe(false);
+ expect(reg.has(null)).toBe(false);
+ });
+
+ it('clear() removes all entries', () => {
+ const reg = new Registry();
+ reg.register('k', 'v');
+ reg.clear();
+ expect(reg.get('k')).toBeNull();
+ expect(reg.size).toBe(0);
+ });
+
+ it('size reflects number of stored entries (including alias keys)', () => {
+ const reg = new Registry();
+ reg.register('a', 'v1', ['b', 'c']);
+ // 3 stored keys: 'a', 'b', 'c'
+ expect(reg.size).toBe(3);
+ });
+
+ it('last registration wins for the same normalised key', () => {
+ const reg = new Registry(k => [k.toLowerCase()]);
+ reg.register('KEY', 'first');
+ reg.register('key', 'second');
+ expect(reg.get('KEY')).toBe('second');
+ });
+
+ // ── values() ───────────────────────────────────────────────────────────────
+
+ it('values() returns all unique values (deduped across alias keys)', () => {
+ const reg = new Registry(k => [k, k.toLowerCase()]);
+ reg.register('Alpha', 'a');
+ reg.register('Beta', 'b');
+ const vals = reg.values();
+ expect(vals).toHaveLength(2);
+ expect(vals).toContain('a');
+ expect(vals).toContain('b');
+ });
+
+ it('values() returns empty array for an empty registry', () => {
+ const reg = new Registry();
+ expect(reg.values()).toEqual([]);
+ });
+
+ // ── filter() ───────────────────────────────────────────────────────────────
+
+ it('filter() returns values matching the predicate', () => {
+ const reg = new Registry();
+ reg.register('one', 1);
+ reg.register('two', 2);
+ reg.register('three', 3);
+ expect(reg.filter(v => v > 1)).toEqual(expect.arrayContaining([2, 3]));
+ expect(reg.filter(v => v > 1)).toHaveLength(2);
+ });
+
+ it('filter() returns empty array when nothing matches', () => {
+ const reg = new Registry();
+ reg.register('one', 1);
+ expect(reg.filter(v => v > 99)).toEqual([]);
+ });
+
+ it('filter() deduplicates values stored under alias keys', () => {
+ const reg = new Registry<{ n: number }>(k => [k, k.toLowerCase()]);
+ const obj = { n: 7 };
+ reg.register('MyKey', obj); // stored under 'MyKey' and 'mykey'
+ const result = reg.filter(v => v.n === 7);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe(obj);
+ });
+});
diff --git a/platform/packages/ui-core/tsconfig.build.json b/platform/packages/ui-core/tsconfig.build.json
new file mode 100644
index 00000000..8bfff951
--- /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..c7596443
--- /dev/null
+++ b/platform/packages/ui-core/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {"customConditions": ["development"], "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,
+ },
+});
diff --git a/platform/packages/vue/GUIDE.md b/platform/packages/vue/GUIDE.md
new file mode 100644
index 00000000..af61105a
--- /dev/null
+++ b/platform/packages/vue/GUIDE.md
@@ -0,0 +1,760 @@
+# Vue Integration Guide: API Client Patterns
+
+**Location:** `platform/packages/vue`
+
+This guide explains how the Vue 3 adapter integrates with `DynamiaClient` and provides composables, components, and reactive views for building Dynamia-based applications.
+
+---
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Setup & Plugin Registration](#setup--plugin-registration)
+3. [Composables Reference](#composables-reference)
+4. [Common Patterns](#common-patterns)
+5. [Testing](#testing)
+6. [Real-World Examples](#real-world-examples)
+
+---
+
+## Overview
+
+The Vue package provides three layers of API integration:
+
+| Layer | Purpose | Files |
+|-------|---------|-------|
+| **Plugin** | Registers global `DynamiaClient` via Vue injection | `src/plugin.ts` |
+| **Composables** | Reactive helpers for common tasks (navigation, forms, CRUD) | `src/composables/*.ts` |
+| **Views** | Reactive Vue extensions of ui-core classes | `src/views/*.ts` |
+
+### Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Application (e.g., App.vue) │
+├─────────────────────────────────────────────────────────────────┤
+│ useDynamiaClient() ──┐ │
+│ useNavigation(client) ├─→ Composables │
+│ useCrud(options) ────┘ │ │
+├──────────────────────────┼──────────────────────────────────────┤
+│ ▼ │
+│ Reactive Views │
+│ (VueCrudView, VueTableView, etc.) │
+├──────────────────────────┬──────────────────────────────────────┤
+│ │ │
+│ client.http (from plugin injection) │
+│ ├─ client.metadata.* │
+│ ├─ client.crud(path) │
+│ ├─ client.actions.* │
+│ └─ Extension APIs (ReportsApi, SaasApi, FilesApi) │
+│ │
+│ ▼ All HTTP flows through HttpClient ▼ │
+│ (Auth headers, fetch, error handling) │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Setup & Plugin Registration
+
+### Step 1: Create DynamiaClient
+
+```typescript
+// lib/client.ts
+import { DynamiaClient } from '@dynamia-tools/sdk';
+
+export const client = new DynamiaClient({
+ baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:8080',
+ token: getAuthToken(), // Your auth logic
+ // OR for session-based:
+ // withCredentials: true,
+});
+```
+
+### Step 2: Register Plugin
+
+```typescript
+// main.ts
+import { createApp } from 'vue';
+import { DynamiaVue } from '@dynamia-tools/vue';
+import App from './App.vue';
+import { client } from './lib/client';
+
+const app = createApp(App);
+app.use(DynamiaVue, { client });
+app.mount('#app');
+```
+
+### Step 3: Use in Components
+
+```typescript
+// components/MyComponent.vue
+
+
+
+
+
+ {{ node.label }}
+
+
+
+ {{ currentPage.label }}
+
+
+```
+
+---
+
+## Composables Reference
+
+### `useDynamiaClient()`
+
+Injects the global `DynamiaClient` provided by the plugin.
+
+**Returns:**
+- `DynamiaClient | null` — The configured client, or null if plugin not initialized
+
+**Example:**
+```typescript
+const client = useDynamiaClient();
+if (client) {
+ const app = await client.metadata.getApp();
+ console.log(app.name);
+}
+```
+
+**When to use:**
+- In top-level components that need direct client access
+- When calling extension APIs (ReportsApi, SaasApi, etc.)
+- In custom loaders
+
+---
+
+### `useNavigation(client, options?)`
+
+Loads and manages the application navigation tree.
+
+**Parameters:**
+- `client: DynamiaClient` — The injected or passed client
+- `options?: UseNavigationOptions`
+ - `autoSelectFirst?: boolean` — Auto-navigate to first page on load (default: `false`)
+
+**Returns:**
+```typescript
+{
+ tree: Ref;
+ nodes: ComputedRef; // Top-level modules
+ currentModule: ComputedRef;
+ currentGroup: ComputedRef;
+ currentPage: ComputedRef;
+ loading: Ref;
+ error: Ref;
+ currentPath: Ref;
+ navigateTo(path: string): void;
+ reload(): Promise;
+ clearCache(): void;
+}
+```
+
+**Example:**
+```typescript
+const client = useDynamiaClient();
+const { nodes, currentModule, currentPage, navigateTo, loading, error } = useNavigation(client!, {
+ autoSelectFirst: true,
+});
+
+// In template
+Loading...
+Error: {{ error }}
+
+
{{ currentModule?.label }}
+
{{ currentPage?.label }}
+
+```
+
+**When to use:**
+- Main layout component for app navigation
+- Page selector UI
+- Breadcrumb navigation
+
+---
+
+### `useTable(options)`
+
+Creates and manages a reactive table view.
+
+**Parameters:**
+```typescript
+{
+ descriptor: ViewDescriptor; // Required
+ entityMetadata?: EntityMetadata | null;
+ loader?: (params) => Promise<{ rows; pagination }>; // Optional
+ autoLoad?: boolean; // Default: true
+}
+```
+
+**Returns:**
+```typescript
+{
+ view: VueTableView;
+ rows: Ref;
+ columns: Ref;
+ pagination: Ref;
+ loading: Ref;
+ selectedItem: Ref;
+ sortField: Ref;
+ sortDir: Ref<'ASC' | 'DESC'>;
+ searchQuery: Ref;
+ load(params?: Record): Promise;
+ sort(field: string): void;
+ search(query: string): void;
+ selectRow(row: unknown): void;
+ nextPage(): void;
+ prevPage(): void;
+}
+```
+
+**Example (with custom loader):**
+```typescript
+const client = useDynamiaClient();
+
+const { view, rows, columns, pagination, load, sort, search } = useTable({
+ descriptor,
+ loader: async (params) => {
+ const data = await client!.crud('store/books').findAll(params);
+ return {
+ rows: data.content,
+ pagination: {
+ page: data.page,
+ pageSize: data.pageSize,
+ total: data.total,
+ totalPages: data.totalPages,
+ },
+ };
+ },
+ autoLoad: true,
+});
+```
+
+**When to use:**
+- Data tables, grids
+- List pages with sorting/pagination/search
+- Read-only data display
+
+---
+
+### `useCrud(options)`
+
+Creates and manages a CRUD view (form + table/tree).
+
+**Parameters:**
+```typescript
+{
+ descriptor: ViewDescriptor; // Required
+ entityMetadata?: EntityMetadata | null;
+ loader?: (params) => Promise<...>;
+ nodeLoader?: (params) => Promise<...>; // For tree view
+ onSave?: (data, mode) => Promise;
+ onDelete?: (entity) => Promise;
+}
+```
+
+**Returns:**
+```typescript
+{
+ view: VueCrudView;
+ mode: Ref<'list' | 'create' | 'edit'>;
+ form: VueFormView;
+ dataSetView: VueTableView | VueTreeView;
+ tableView: VueTableView | null;
+ treeView: VueTreeView | null;
+ showForm: ComputedRef;
+ showDataSet: ComputedRef;
+ loading: Ref