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 @@ + + + + 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 paramClass = getDefaultParameterClass();
@@ -623,7 +612,7 @@ public static Class 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 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 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 + + + +``` + +--- + +## 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; + error: Ref; + startCreate(): void; + startEdit(entity: unknown): void; + cancelEdit(): void; + save(): Promise; + remove(entity: unknown): Promise; +} +``` + +**Example (Full CRUD with custom handlers):** +```typescript +const client = useDynamiaClient(); + +const { view, mode, form, dataSetView, showForm, showDataSet, startCreate, save, remove } = useCrud({ + descriptor, + loader: async (params) => { + const data = await client!.crud('store/books').findAll(params); + return { rows: data.content, pagination: {...} }; + }, + onSave: async (data, mode) => { + if (mode === 'create') { + await client!.crud('store/books').create(data); + } else { + await client!.crud('store/books').update(data.id, data); + } + }, + onDelete: async (entity) => { + await client!.crud('store/books').delete(entity.id); + }, +}); +``` + +**When to use:** +- Full CRUD pages (list + form + actions) +- Master-detail patterns +- Complex entity management UI + +--- + +### `useForm(options)` + +Creates and manages a reactive form view. + +**Parameters:** +```typescript +{ + descriptor: ViewDescriptor; + entityMetadata?: EntityMetadata | null; + initialValue?: unknown; +} +``` + +**Returns:** +```typescript +{ + view: VueFormView; + fields: Ref; + values: Reactive>; + errors: Ref>; + loading: Ref; + setFieldValue(name: string, value: unknown): void; + validate(): boolean; + getValue(): unknown; +} +``` + +**When to use:** +- Data entry forms +- Edit dialogs +- Configuration panels + +--- + +### `useEntityPicker(options)` + +Creates and manages an entity search/picker view. + +**Parameters:** +```typescript +{ + descriptor: ViewDescriptor; + entityMetadata?: EntityMetadata | null; + searcher?: (query: string) => Promise; + initialValue?: unknown; +} +``` + +**Returns:** +```typescript +{ + view: VueEntityPickerView; + searchResults: Ref; + selectedEntity: Ref; + searchQuery: Ref; + loading: Ref; + search(query: string): void; + select(entity: unknown): void; + clear(): void; +} +``` + +**Example:** +```typescript +const client = useDynamiaClient(); + +const { view, searchResults, search, select } = useEntityPicker({ + descriptor, + searcher: async (query) => { + const results = await client!.metadata.findEntityReferences('authors', query); + return results; + }, +}); +``` + +**When to use:** +- Entity selection fields (lookups, foreign keys) +- Autocomplete search UI +- Modal/dropdown pickers + +--- + +## Common Patterns + +### Pattern 1: Navigation + List + Detail + +```typescript + + + +``` + +### Pattern 2: Entity Picker Field + +```typescript + + + +``` + +### Pattern 3: Custom Extension API in Loader + +```typescript + +``` + +--- + +## Testing + +### Unit Test: Composable with Mock Client + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { useTable } from '@dynamia-tools/vue'; +import { DynamiaClient } from '@dynamia-tools/sdk'; + +describe('useTable', () => { + it('loads data and updates rows', async () => { + // Mock loader + const mockLoader = vi.fn().mockResolvedValue({ + rows: [{ id: 1, name: 'Book 1' }], + pagination: { page: 1, pageSize: 10, total: 100 }, + }); + + const { rows, load } = useTable({ + descriptor: mockDescriptor, + loader: mockLoader, + }); + + expect(rows.value).toEqual([]); + await load(); + expect(rows.value).toEqual([{ id: 1, name: 'Book 1' }]); + expect(mockLoader).toHaveBeenCalled(); + }); +}); +``` + +### Component Test: Navigation Integration + +```typescript +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { DynamiaClient } from '@dynamia-tools/sdk'; +import { DynamiaVue } from '@dynamia-tools/vue'; +import MyApp from './App.vue'; + +describe('App with Navigation', () => { + it('loads navigation on mount', async () => { + const mockClient = new DynamiaClient({ + baseUrl: 'https://api.example.com', + token: 'test', + fetch: mockFetch, + }); + + const wrapper = mount(MyApp, { + global: { + plugins: [[DynamiaVue, { client: mockClient }]], + }, + }); + + await wrapper.vm.$nextTick(); + expect(wrapper.html()).toContain('Navigation loaded'); + }); +}); +``` + +--- + +## Real-World Examples + +### Example 1: Dashboard with Reports + +```typescript + + + +``` + +### Example 2: Master-Detail with Custom Handlers + +```typescript + + + + + +``` + +--- + +## Best Practices + +1. **Always inject the client at the top level** — Use `useDynamiaClient()` in top-level components, pass to child composables +2. **Use loaders for data fetching** — Don't call APIs directly in view components; use loader callbacks +3. **Handle errors gracefully** — Composables expose `error` and `loading` refs; use them in templates +4. **Cache navigation** — `useNavigation()` caches the tree; call `reload()` only when necessary +5. **Test with mocks** — Always provide mock loaders in tests; avoid network calls +6. **Type your data** — Use TypeScript generics: `useTable(...)`, `useCrud(...)` + +--- + +## See Also + +- [API Client Standards](../API_CLIENT_STANDARDS.md) +- [API Client Audit Report](../API_CLIENT_AUDIT_REPORT.md) +- [SDK README](../../platform/packages/sdk/README.md) +- [Demo App](../../examples/demo-vue-books/src/App.vue) + diff --git a/platform/packages/vue/README.md b/platform/packages/vue/README.md new file mode 100644 index 00000000..31a522a3 --- /dev/null +++ b/platform/packages/vue/README.md @@ -0,0 +1,844 @@ +# @dynamia-tools/vue + +> Vue 3 adapter for **Dynamia Platform** — reactive views, composables and components built on `@dynamia-tools/ui-core`. + +`@dynamia-tools/vue` wraps every `ui-core` view class with Vue `ref`/`computed` reactivity, provides composables for a clean developer experience, and ships a set of thin Vue components — all the way down to individual field inputs. The central component is ``, which resolves any view type automatically, mirroring ZK's `Viewer` on the backend. For full app shells driven by `NavigationTree`, `` can render `NavigationNode` entries of type `CrudPage` end-to-end. + +--- + +## Table of Contents + +- [Installation](#installation) +- [Plugin Setup](#plugin-setup) +- [Quick Start](#quick-start) +- [Universal Component: ``](#universal-component-dynamiaviewer) +- [Composables](#composables) + - [useViewer](#useviewer) + - [useView](#useview) + - [useForm](#useform) + - [useTable](#usetable) + - [useCrud](#usecrud) + - [useCrudPage](#usecrudpage) + - [useEntityPicker](#useentitypicker) + - [useNavigation](#usenavigation) +- [Full App Shell: Auto navigation + CrudPage rendering](#full-app-shell-auto-navigation--crudpage-rendering) +- [Vue-reactive View classes](#vue-reactive-view-classes) + - [VueViewer](#vueviewer) + - [VueFormView](#vueformview) + - [VueTableView](#vuetableview) + - [VueCrudView](#vuecrudview) +- [Components](#components) + - [Form.vue](#formvue) + - [Table.vue](#tablevue) + - [Crud.vue](#crudvue) + - [CrudPage.vue](#crudpagevue) + - [Field.vue](#fieldvue) + - [Field components](#field-components) + - [Actions.vue](#actionsvue) + - [NavMenu.vue](#navmenuvue) + - [NavBreadcrumb.vue](#navbreadcrumbvue) +- [Custom ViewType example](#custom-viewtype-example) +- [Architecture](#architecture) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Installation + +```bash +# pnpm (recommended) +pnpm add @dynamia-tools/vue @dynamia-tools/ui-core @dynamia-tools/sdk vue + +# npm +npm install @dynamia-tools/vue @dynamia-tools/ui-core @dynamia-tools/sdk vue + +# yarn +yarn add @dynamia-tools/vue @dynamia-tools/ui-core @dynamia-tools/sdk vue +``` + +`vue >= 3.4`, `@dynamia-tools/ui-core` and `@dynamia-tools/sdk` are peer dependencies. + +--- + +## Plugin Setup + +Register the plugin once in your application entry point. It registers all built-in view renderers, view factories and global components: + +```typescript +// main.ts +import { createApp } from 'vue'; +import { DynamiaVue } from '@dynamia-tools/vue'; +import App from './App.vue'; + +const app = createApp(App); +app.use(DynamiaVue); +app.mount('#app'); +``` + +After `app.use(DynamiaVue)` the following components are available globally (no import needed in templates): + +| Component | Description | +|-----------|-------------| +| `` | Universal view host — resolves any view type | +| `` | Form rendering | +| `` | Table rendering | +| `` | Full CRUD (form + table + actions) | +| `` | Single field dispatcher | +| `` | Action toolbar | +| `` | Navigation sidebar/menu | +| `` | Navigation breadcrumb | +| `` | Fully wired CRUD page for `NavigationNode.type === 'CrudPage'` | + +--- + +## Quick Start + +```vue + + + +``` + +--- + +## Universal Component: `` + +`` is the **primary component** for rendering any view type. It handles descriptor resolution, view initialization, loading state, and error display automatically. + +```vue + + + + + + + + + + + + + + + + + + + +``` + +**Props:** + +| Prop | Type | Description | +|------|------|-------------| +| `viewType` | `string` | View type name: `'form'`, `'table'`, `'crud'`, `'tree'`, `'kanban'`, … | +| `beanClass` | `string` | Fully-qualified entity class name | +| `descriptor` | `ViewDescriptor` | Pre-loaded descriptor (skips backend fetch) | +| `descriptorId` | `string` | Descriptor ID to fetch from backend | +| `readOnly` | `boolean` | Propagates to the inner view | + +**Events:** + +| Event | Payload | Description | +|-------|---------|-------------| +| `ready` | `View` | Emitted when the view is initialized | +| `error` | `string` | Emitted on initialization failure | + +**Slots:** + +| Slot | Props | Description | +|------|-------|-------------| +| `loading` | — | Shown during initialization | +| `error` | `{ error: string }` | Shown on failure | +| `unsupported` | — | Shown when no renderer is registered for the view type | + +--- + +## Composables + +### useViewer + +The primary composable. Creates a `VueViewer`, initializes it on mount, destroys it on unmount. + +```typescript +import { useViewer } from '@dynamia-tools/vue'; +import { DynamiaClient } from '@dynamia-tools/sdk'; + +const client = new DynamiaClient({ baseUrl: '/api', token: '...' }); + +const { viewer, view, loading, error, getValue, setValue, setReadonly } = useViewer({ + viewType: 'form', + beanClass: 'com.example.Book', + client, + value: { title: 'Clean Code' }, // optional initial value + readOnly: false, +}); + +// viewer — VueViewer instance (full class API) +// view — ShallowRef — reactive resolved view +// loading — Ref +// error — Ref +``` + +### useView + +Generic lifecycle composable for any `VueView` subclass. + +```typescript +import { useView, VueFormView } from '@dynamia-tools/vue'; + +const { view, loading, error, initialized } = useView( + () => new VueFormView(descriptor, metadata), +); + +// view — VueFormView +// loading — Ref +// error — Ref +// initialized — Ref +``` + +### useForm + +Direct access to a `VueFormView`: + +```typescript +import { useForm } from '@dynamia-tools/vue'; + +const { view, values, errors, loading, fields, layout, validate, submit, reset, setFieldValue } = + useForm({ + descriptor, // required: ViewDescriptor + entityMetadata, // optional: EntityMetadata + initialData: { title: '...' }, // optional initial form data + }); + +// values — Ref> +// errors — Ref> +// fields — ComputedRef +// layout — ComputedRef + +values.value.title = 'New Title'; +validate(); // boolean +await submit(); // emits 'submit' on view +reset(); +``` + +### useTable + +Direct access to a `VueTableView`: + +```typescript +import { useTable } from '@dynamia-tools/vue'; + +const { view, rows, columns, pagination, loading, sort, search, load, nextPage, prevPage } = + useTable({ + descriptor, + entityMetadata, + autoLoad: true, // load on mount (default: true) + loader: 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: 0, + }, + }; + }, + }); + +// rows — Ref +// columns — ComputedRef +// pagination — Ref + +await sort('title'); +await search('clean'); +await nextPage(); +``` + +### useCrud + +Full CRUD lifecycle (form + table + mode state machine): + +```typescript +import { useCrud } from '@dynamia-tools/vue'; + +const { view, mode, form, table, showForm, showTable, startCreate, startEdit, cancelEdit, save, remove } = + useCrud({ + descriptor, + loader: async (params) => { /* fetch rows */ }, + onSave: async (data, mode) => { + if (mode === 'create') await client.crud('books').create(data); + else await client.crud('books').update(data.id, data); + }, + onDelete: async (entity) => { + await client.crud('books').delete(entity.id); + }, + }); + +// mode — Ref<'list' | 'create' | 'edit'> +// showForm — ComputedRef +// showTable — ComputedRef +// form — VueFormView +// table — VueTableView +``` + +### useEntityPicker + +Entity search and selection: + +```typescript +import { useEntityPicker } from '@dynamia-tools/vue'; + +const { view, searchResults, selectedEntity, searchQuery, loading, search, select, clear } = + useEntityPicker({ + descriptor, + searcher: async (query) => { + const result = await client.crud('books').findAll({ q: query }); + return result.content; + }, + initialValue: currentBook, + }); +``` + +### useCrudPage + +Builds a complete CRUD page from a navigation node of type `CrudPage`. It resolves metadata + descriptor via `CrudPageResolver`, wires table loading and save/delete handlers, initializes the view, and loads the first page. + +```typescript +import { useCrudPage } from '@dynamia-tools/vue'; + +const { view, loading, error, reload } = useCrudPage({ + node, // NavigationNode (type: 'CrudPage') + client, // DynamiaClient +}); + +// view — Ref +// loading — Ref +// error — Ref +// reload — () => Promise +``` + +### useNavigation + +Fetches and caches the application navigation tree. Uses SDK types directly — no new types defined. + +```typescript +import { useNavigation } from '@dynamia-tools/vue'; + +const { + tree, + nodes, + currentModule, + currentGroup, + currentPage, + currentPath, + loading, + navigateTo, + clearCache, + reload, +} = useNavigation(client, { autoSelectFirst: true }); + +// tree — Ref +// nodes — ComputedRef +// currentModule — ComputedRef +// currentGroup — ComputedRef +// currentPage — ComputedRef +// currentPath — Ref + +navigateTo('/pages/store/books'); +``` + +The navigation tree is cached in module memory after the first fetch. Call `clearCache()` and then `reload()` to force a re-fetch. +Set `autoSelectFirst: true` to automatically navigate to the first available leaf page after loading the tree. + +--- + +## Full App Shell: Auto navigation + CrudPage rendering + +With `useNavigation` + `useCrudPage` + ``, you can build complete metadata-driven apps where: + +- navigation loads automatically on mount, +- the first available page is selected automatically, +- `CrudPage` nodes render instantly without manual CRUD wiring. + +```vue + + + +``` + +--- + +## Vue-reactive View classes + +All view classes extend their `ui-core` counterparts and replace state with Vue `ref`/`computed`: + +### VueViewer + +```typescript +import { VueViewer } from '@dynamia-tools/vue'; + +const viewer = new VueViewer({ viewType: 'form', beanClass: 'com.example.Book', client }); +await viewer.initialize(); + +viewer.loading.value // Ref +viewer.error.value // Ref +viewer.currentView.value // ShallowRef +viewer.currentDescriptor.value // ShallowRef +``` + +### VueFormView + +```typescript +import { VueFormView } from '@dynamia-tools/vue'; + +const form = new VueFormView(descriptor, metadata); +await form.initialize(); + +form.values.value // Ref> +form.errors.value // Ref> +form.isLoading.value // Ref +form.isDirty.value // Ref +form.resolvedFields.value // ComputedRef +form.layout.value // ComputedRef +``` + +### VueTableView + +```typescript +import { VueTableView } from '@dynamia-tools/vue'; + +const table = new VueTableView(descriptor, metadata); +table.setLoader(loader); +await table.initialize(); +await table.load(); + +table.rows.value // Ref +table.columns.value // ComputedRef +table.pagination.value // Ref +table.isLoading.value // Ref +table.selectedRow.value// Ref +``` + +### VueCrudView + +```typescript +import { VueCrudView } from '@dynamia-tools/vue'; + +const crud = new VueCrudView(descriptor, metadata); +await crud.initialize(); + +crud.mode.value // Ref<'list' | 'create' | 'edit'> +crud.showForm.value // ComputedRef +crud.showTable.value // ComputedRef +crud.formView // VueFormView +crud.tableView // VueTableView +``` + +--- + +## Components + +### Form.vue + +Renders a `VueFormView` using the computed grid layout. Uses `` to render each cell. + +```vue + + + + +``` + +### Table.vue + +Renders a `VueTableView` with header, rows, empty state and pagination. + +```vue + + + + + + + + +``` + +### Crud.vue + +Combines `` and `` into a full CRUD interface with mode transitions. + +```vue + +``` + +### CrudPage.vue + +Renders a full CRUD page directly from a navigation node of type `CrudPage`. + +```vue + +``` + +`` internally uses `useCrudPage()` and exposes loading/error slots: + +```vue + + + + +``` + +### Field.vue + +Dispatches to the correct field component based on `field.resolvedComponent`. Falls back to a plain `` for unknown component types. + +```vue + +``` + +### Field components + +All field components live in `src/components/fields/` and are loaded lazily via `defineAsyncComponent`: + +| Component | ZK Equivalent | Description | +|-----------|--------------|-------------| +| `Textbox.vue` | `Textbox` | Single-line text input | +| `Textareabox.vue` | `Textareabox` | Multi-line textarea | +| `Intbox.vue` | `Intbox` | Integer number input | +| `Spinner.vue` | `Spinner` / `Doublespinner` | Decimal number input | +| `Combobox.vue` | `Combobox` | Dropdown select | +| `Datebox.vue` | `Datebox` | Date / datetime-local input | +| `Checkbox.vue` | `Checkbox` | Boolean checkbox | +| `EntityPicker.vue` | `EntityPicker` | Search-based entity selection | +| `EntityRefPicker.vue` | `EntityRefPicker` | Reference entity picker | +| `EntityRefLabel.vue` | `EntityRefLabel` | Read-only entity reference display | +| `CoolLabel.vue` | `CoolLabel` | Image + title + subtitle + description | +| `Link.vue` | `Link` | Clickable link that triggers an action/event | + +All field components accept these common props: + +```typescript +interface FieldProps { + field: ResolvedField; // resolved field descriptor + modelValue?: unknown; // current value (v-model) + readOnly?: boolean; // disables editing + params?: Record; // descriptor params +} +``` + +And emit `update:modelValue` for `v-model` support. + +**Combobox options** can be provided via `params.options` or `params.values`: + +```yaml +# descriptor YAML +fields: + status: + params: + options: + - { value: 'ACTIVE', label: 'Active' } + - { value: 'INACTIVE', label: 'Inactive' } +``` + +**EntityPicker search** is provided at runtime via `params.searcher`: + +```typescript +field.params['searcher'] = async (query: string) => { + return (await client.crud('authors').findAll({ q: query })).content; +}; +``` + +### Actions.vue + +Renders a list of `ActionMetadata` as buttons in a toolbar. + +```vue + +``` + +**Props:** + +| Prop | Type | Description | +|------|------|-------------| +| `actions` | `ActionMetadata[]` | Actions to render | +| `view` | `View` | The view this toolbar belongs to | + +**Events:** + +| Event | Payload | Description | +|-------|---------|-------------| +| `action` | `ActionMetadata` | Emitted when an action button is clicked | + +### NavMenu.vue + +Renders a `NavigationTree` as a sidebar menu. Uses SDK types directly. + +```vue + +``` + +**Props:** + +| Prop | Type | Description | +|------|------|-------------| +| `nodes` | `NavigationNode[]` | Top-level navigation nodes (modules) | +| `currentPath` | `string \| null` | Currently active virtual path | + +**Events:** + +| Event | Payload | Description | +|-------|---------|-------------| +| `navigate` | `string` | Emitted with virtual path when a page is clicked | + +### NavBreadcrumb.vue + +Renders the current page location as a breadcrumb trail. + +```vue + +``` + +--- + +## Custom ViewType example + +The plugin architecture is open — add new view types without modifying the core packages: + +```typescript +// kanban-plugin.ts +import type { App } from 'vue'; +import type { ViewType, View, ViewRenderer, ResolvedField } from '@dynamia-tools/ui-core'; +import { ViewRendererRegistry } from '@dynamia-tools/ui-core'; +import type { ViewDescriptor, EntityMetadata } from '@dynamia-tools/sdk'; +import KanbanBoard from './KanbanBoard.vue'; + +// 1. Define the view type +const KanbanViewType: ViewType = { name: 'kanban' }; + +// 2. Implement a View subclass (in ui-core) +class KanbanView extends View { + constructor(d: ViewDescriptor, m: EntityMetadata | null) { + super(KanbanViewType, d, m); + } + async initialize() { /* fetch board data */ } + validate() { return true; } +} + +// 3. Implement a Vue renderer +class VueKanbanRenderer implements ViewRenderer { + readonly supportedViewType = KanbanViewType; + render(_view: KanbanView) { return KanbanBoard; } +} + +// 4. Export as a Vue plugin +export const KanbanPlugin = { + install(app: App) { + ViewRendererRegistry.register(KanbanViewType, new VueKanbanRenderer()); + ViewRendererRegistry.registerViewFactory(KanbanViewType, (d, m) => new KanbanView(d, m)); + app.component('KanbanBoard', KanbanBoard); + }, +}; +``` + +```typescript +// main.ts +app.use(DynamiaVue); +app.use(KanbanPlugin); +``` + +```vue + + +``` + +--- + +## Architecture + +``` +@dynamia-tools/vue +│ +├── views/ +│ ├── VueView.ts ← abstract: extends View + Vue reactivity base +│ ├── VueViewer.ts ← extends Viewer — reactive universal resolution host +│ ├── VueFormView.ts ← extends FormView — values/errors as Vue refs +│ ├── VueTableView.ts ← extends TableView — rows/pagination as Vue refs +│ ├── VueCrudView.ts ← extends CrudView — owns VueFormView + VueTableView +│ ├── VueTreeView.ts +│ ├── VueConfigView.ts +│ └── VueEntityPickerView.ts +│ +├── renderers/ +│ ├── VueFormRenderer.ts ← implements FormRenderer +│ ├── VueTableRenderer.ts ← implements TableRenderer +│ ├── VueCrudRenderer.ts ← implements CrudRenderer +│ └── VueFieldRenderer.ts ← implements FieldRenderer +│ +├── composables/ +│ ├── useViewer.ts ← primary API (resolves any view type) +│ ├── useView.ts ← generic view lifecycle +│ ├── useForm.ts +│ ├── useTable.ts +│ ├── useCrud.ts +│ ├── useCrudPage.ts ← auto-builds VueCrudView from a CrudPage node +│ ├── useEntityPicker.ts +│ └── useNavigation.ts +│ +├── components/ +│ ├── Viewer.vue ← universal host (primary component) +│ ├── Form.vue +│ ├── Table.vue +│ ├── Crud.vue +│ ├── CrudPage.vue ← renders CrudPage NavigationNodes end-to-end +│ ├── Field.vue ← field dispatcher +│ ├── Actions.vue +│ ├── NavMenu.vue +│ ├── NavBreadcrumb.vue +│ └── fields/ +│ ├── Textbox.vue +│ ├── Textareabox.vue +│ ├── Intbox.vue +│ ├── Spinner.vue +│ ├── Combobox.vue +│ ├── Datebox.vue +│ ├── Checkbox.vue +│ ├── EntityPicker.vue +│ ├── EntityRefPicker.vue +│ ├── EntityRefLabel.vue +│ ├── CoolLabel.vue +│ └── Link.vue +│ +└── plugin.ts ← DynamiaVue plugin (registers all renderers + components) +``` + +**Design principles:** + +- **No Pinia** — state lives inside `View`/`Viewer` instances as `ref`/`shallowRef`. Pinia integration is an application-level concern. +- **No type duplication** — all SDK types (`ViewDescriptor`, `EntityMetadata`, `NavigationTree`, …) are imported, never redefined. +- **Lazy field components** — field components are loaded via `defineAsyncComponent` to keep the main bundle small. +- **Open extension** — `ViewType` and `FieldComponent` are plain objects, not enums. Third-party modules extend them without touching core. + +--- + +## Contributing + +See the monorepo [CONTRIBUTING.md](../../../CONTRIBUTING.md) for full guidelines. + +```bash +# Install all workspace dependencies +pnpm install + +# Build vue package (builds ui-core first as dependency) +pnpm --filter @dynamia-tools/vue build + +# Type-check +pnpm --filter @dynamia-tools/vue typecheck + +# Build entire workspace +pnpm run build +``` + +--- + +## License + +[Apache License 2.0](../../../LICENSE) — © Dynamia Soluciones IT SAS diff --git a/platform/packages/vue/package.json b/platform/packages/vue/package.json new file mode 100644 index 00000000..95139bc1 --- /dev/null +++ b/platform/packages/vue/package.json @@ -0,0 +1,46 @@ +{ + "name": "@dynamia-tools/vue", + "version": "26.3.2", + "description": "Vue 3 adapter for Dynamia Platform UI", + "keywords": ["dynamia", "vue", "viewer", "form", "table", "crud", "typescript"], + "homepage": "https://dynamia.tools", + "repository": {"type": "git", "url": "https://github.com/dynamiatools/framework.git", "directory": "platform/packages/vue"}, + "license": "Apache-2.0", + "author": "Dynamia Soluciones IT SAS", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "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", + "typecheck": "vue-tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@dynamia-tools/sdk": "workspace:*", + "@dynamia-tools/ui-core": "workspace:*" + }, + "peerDependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@dynamia-tools/sdk": "workspace:*", + "@dynamia-tools/ui-core": "workspace:*", + "@types/node": "^22.0.0", + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.7.0", + "vite": "^6.2.0", + "vite-plugin-dts": "^4.5.0", + "vue": "^3.4.0", + "vue-tsc": "^2.0.0" + }, + "publishConfig": {"access": "public", "registry": "https://registry.npmjs.org/"} +} diff --git a/platform/packages/vue/src/action-renderers/VueButtonActionRenderer.ts b/platform/packages/vue/src/action-renderers/VueButtonActionRenderer.ts new file mode 100644 index 00000000..913d9dea --- /dev/null +++ b/platform/packages/vue/src/action-renderers/VueButtonActionRenderer.ts @@ -0,0 +1,47 @@ +import { defineComponent, h, type PropType } from 'vue'; +import type { ActionMetadata } from '@dynamia-tools/sdk'; +import type { View, ActionTriggerPayload } from '@dynamia-tools/ui-core'; + +export const VueButtonActionRenderer = defineComponent({ + name: 'VueButtonActionRenderer', + props: { + action: { + type: Object as PropType, + required: true, + }, + view: { + type: Object as PropType, + default: undefined, + }, + disabled: { + type: Boolean, + default: false, + }, + executing: { + type: Boolean, + default: false, + }, + }, + emits: { + trigger: (_payload?: ActionTriggerPayload) => true, + }, + setup(props, { emit }) { + return () => h( + 'button', + { + type: 'button', + class: 'dynamia-action-btn', + title: props.action.description, + disabled: props.disabled || props.executing, + onClick: () => emit('trigger', {}), + }, + [ + props.action.icon + ? h('span', { class: ['dynamia-action-icon', props.action.icon], 'aria-hidden': 'true' }) + : null, + h('span', { class: 'dynamia-action-label' }, props.executing ? `${props.action.name}…` : props.action.name), + ], + ); + }, +}); + diff --git a/platform/packages/vue/src/actions/crudActionUtils.ts b/platform/packages/vue/src/actions/crudActionUtils.ts new file mode 100644 index 00000000..4d1a4c19 --- /dev/null +++ b/platform/packages/vue/src/actions/crudActionUtils.ts @@ -0,0 +1,36 @@ +import type { ActionMetadata } from '@dynamia-tools/sdk'; + +function normalize(value?: string | null): string { + return (value ?? '').trim().toLowerCase(); +} + +export function matchesActionIdentity(action: ActionMetadata, ...identities: string[]): boolean { + const id = normalize(action.id); + const className = normalize(action.className); + + return identities.some(identity => { + const normalizedIdentity = normalize(identity); + return normalizedIdentity === id || normalizedIdentity === className; + }); +} + +export function isCreateCrudAction(action: ActionMetadata): boolean { + return matchesActionIdentity(action, 'newaction', 'new', 'create', 'createaction'); +} + +export function isEditCrudAction(action: ActionMetadata): boolean { + return matchesActionIdentity(action, 'editaction', 'edit'); +} + +export function isDeleteCrudAction(action: ActionMetadata): boolean { + return matchesActionIdentity(action, 'deleteaction', 'delete'); +} + +export function isSaveCrudAction(action: ActionMetadata): boolean { + return matchesActionIdentity(action, 'saveaction', 'save'); +} + +export function isCancelCrudAction(action: ActionMetadata): boolean { + return matchesActionIdentity(action, 'cancelaction', 'cancel'); +} + diff --git a/platform/packages/vue/src/components/Actions.vue b/platform/packages/vue/src/components/Actions.vue new file mode 100644 index 00000000..ead5d37f --- /dev/null +++ b/platform/packages/vue/src/components/Actions.vue @@ -0,0 +1,208 @@ + + + + diff --git a/platform/packages/vue/src/components/Crud.vue b/platform/packages/vue/src/components/Crud.vue new file mode 100644 index 00000000..72a1ee56 --- /dev/null +++ b/platform/packages/vue/src/components/Crud.vue @@ -0,0 +1,142 @@ + + + + diff --git a/platform/packages/vue/src/components/CrudPage.vue b/platform/packages/vue/src/components/CrudPage.vue new file mode 100644 index 00000000..840d25bc --- /dev/null +++ b/platform/packages/vue/src/components/CrudPage.vue @@ -0,0 +1,96 @@ + + + + + + diff --git a/platform/packages/vue/src/components/Field.vue b/platform/packages/vue/src/components/Field.vue new file mode 100644 index 00000000..09fc3265 --- /dev/null +++ b/platform/packages/vue/src/components/Field.vue @@ -0,0 +1,70 @@ + + + + diff --git a/platform/packages/vue/src/components/Form.vue b/platform/packages/vue/src/components/Form.vue new file mode 100644 index 00000000..5857c10e --- /dev/null +++ b/platform/packages/vue/src/components/Form.vue @@ -0,0 +1,103 @@ + + + + + + + diff --git a/platform/packages/vue/src/components/NavBreadcrumb.vue b/platform/packages/vue/src/components/NavBreadcrumb.vue new file mode 100644 index 00000000..493b922e --- /dev/null +++ b/platform/packages/vue/src/components/NavBreadcrumb.vue @@ -0,0 +1,31 @@ + + + + diff --git a/platform/packages/vue/src/components/NavMenu.vue b/platform/packages/vue/src/components/NavMenu.vue new file mode 100644 index 00000000..5d934bd0 --- /dev/null +++ b/platform/packages/vue/src/components/NavMenu.vue @@ -0,0 +1,62 @@ + + + + diff --git a/platform/packages/vue/src/components/Table.vue b/platform/packages/vue/src/components/Table.vue new file mode 100644 index 00000000..b193ed97 --- /dev/null +++ b/platform/packages/vue/src/components/Table.vue @@ -0,0 +1,77 @@ + + + + diff --git a/platform/packages/vue/src/components/Tree.vue b/platform/packages/vue/src/components/Tree.vue new file mode 100644 index 00000000..49608773 --- /dev/null +++ b/platform/packages/vue/src/components/Tree.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/platform/packages/vue/src/components/Viewer.vue b/platform/packages/vue/src/components/Viewer.vue new file mode 100644 index 00000000..6d5d3782 --- /dev/null +++ b/platform/packages/vue/src/components/Viewer.vue @@ -0,0 +1,95 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/Checkbox.vue b/platform/packages/vue/src/components/fields/Checkbox.vue new file mode 100644 index 00000000..771e808e --- /dev/null +++ b/platform/packages/vue/src/components/fields/Checkbox.vue @@ -0,0 +1,32 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/Combobox.vue b/platform/packages/vue/src/components/fields/Combobox.vue new file mode 100644 index 00000000..193bd723 --- /dev/null +++ b/platform/packages/vue/src/components/fields/Combobox.vue @@ -0,0 +1,71 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/CoolLabel.vue b/platform/packages/vue/src/components/fields/CoolLabel.vue new file mode 100644 index 00000000..676ff147 --- /dev/null +++ b/platform/packages/vue/src/components/fields/CoolLabel.vue @@ -0,0 +1,40 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/Datebox.vue b/platform/packages/vue/src/components/fields/Datebox.vue new file mode 100644 index 00000000..1cb5768b --- /dev/null +++ b/platform/packages/vue/src/components/fields/Datebox.vue @@ -0,0 +1,50 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/EntityPicker.vue b/platform/packages/vue/src/components/fields/EntityPicker.vue new file mode 100644 index 00000000..67f8327a --- /dev/null +++ b/platform/packages/vue/src/components/fields/EntityPicker.vue @@ -0,0 +1,332 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/EntityRefLabel.vue b/platform/packages/vue/src/components/fields/EntityRefLabel.vue new file mode 100644 index 00000000..76ede9b5 --- /dev/null +++ b/platform/packages/vue/src/components/fields/EntityRefLabel.vue @@ -0,0 +1,25 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/EntityRefPicker.vue b/platform/packages/vue/src/components/fields/EntityRefPicker.vue new file mode 100644 index 00000000..71dea5af --- /dev/null +++ b/platform/packages/vue/src/components/fields/EntityRefPicker.vue @@ -0,0 +1,294 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/Intbox.vue b/platform/packages/vue/src/components/fields/Intbox.vue new file mode 100644 index 00000000..59ee136b --- /dev/null +++ b/platform/packages/vue/src/components/fields/Intbox.vue @@ -0,0 +1,34 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/Link.vue b/platform/packages/vue/src/components/fields/Link.vue new file mode 100644 index 00000000..7a81eeaf --- /dev/null +++ b/platform/packages/vue/src/components/fields/Link.vue @@ -0,0 +1,34 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/Spinner.vue b/platform/packages/vue/src/components/fields/Spinner.vue new file mode 100644 index 00000000..ea93bc21 --- /dev/null +++ b/platform/packages/vue/src/components/fields/Spinner.vue @@ -0,0 +1,34 @@ + + + + diff --git a/platform/packages/vue/src/components/fields/Textareabox.vue b/platform/packages/vue/src/components/fields/Textareabox.vue new file mode 100644 index 00000000..10fbb2bc --- /dev/null +++ b/platform/packages/vue/src/components/fields/Textareabox.vue @@ -0,0 +1,32 @@ + +