Compare commits

...

76 Commits

Author SHA1 Message Date
Loretta 92296c479b Add XML doc standards; enable compiler-generated files
Added "XML Documentation" section to CONVENTIONS.md, specifying usage of <summary> tags for developer-facing comments and restricting implementation details to external docs. Enabled EmitCompilerGeneratedFiles in FruitBank.Common.csproj for improved debugging and analysis.
2026-05-23 09:27:29 +02:00
Loretta b61b8085df [LOADED_DOCS: 3 files, no new loads]
Refactor SIGDS docs, archive DEC log, add pipe tests

- Updated all references to AcSignalRDataSource docs to new SIGNALR_DATASOURCE/README.md location; introduced SIGDS topic and paired issues/TODO files.
- Implemented new Decision Log archival policy: last-15-active entries remain, older entries moved to year-month archive (LLMP-DEC-65, 67); updated docs-archive skill for two-rule rotation.
- Added new SIGDS architectural TODO (ACCORE-SIGDS-T-D9F2) for relocating DataSource code.
- Updated doc tables, glossaries, and conventions for SIGDS.
- Added AcBinarySerializerPipeParallelTests.cs for parallel serialization/deserialization round-trip tests.
2026-04-28 06:36:39 +02:00
Loretta 3cee1e20d7 [LOADED_DOCS: 3 files, no new loads]
Update ID format to use per-repo prefixes and random suffix

Migrated all issue, TODO, and decision IDs to a new 4-part format: <PREFIX>-<TOPIC>-<TYPE>-<RAND>. Added per-repo prefix declarations in copilot-instructions.md and documented conventions in REPO_PREFIXES.md. Updated all topic registries, logs, cross-references, and documentation to use the new format. Introduced MIGRATION_ID_MAPPING.md for old-to-new ID mapping. Enhanced skills and protocol audit logic to validate and enforce per-repo prefixes and topic codes at runtime. Clarified Framework-First doctrine and ensured all references are unambiguous.
2026-04-26 19:12:50 +02:00
Loretta b4d68e1fa4 [LOADED_DOCS: 3 files, no new loads]
Add Rule #6, AUTH topic, ADRs, config & doc updates

- Codified Rule #6 (authority, rule scope, skill invocation) in all primary copilot-instructions.md files
- Clarified skill pre-load/lazy-load rules and LOADED_DOCS prefix
- Forbid skill/template version labels in Decision Log governance
- Scaffolded new AUTH topic with README, ISSUES, and TODO files
- Added repo/project ADR folders and templates; new ADR for AcBinaryHubProtocol decorator stack
- Migrated cross-cutting issues/TODOs to Closed with detailed resolution
- Made FruitBankHybrid.Shared/appsettings.json the canonical config source; suppressed Razor SDK auto-publish to avoid file collisions
- Updated protocol/wire format docs for AcBinaryHubProtocol
- Minor config: updated ports, WaitForFlush, and csproj content rules
2026-04-26 13:44:12 +02:00
Loretta 1a7bb3bc10 Add adr-author skill, ADR template, and log security issues
- Introduced the `adr-author` skill for structured ADR creation; updated session setup and shared skills to require pre-loading it.
- Added `SKILL.md` and `ADR_TEMPLATE.md` for ADR authoring workflow and documentation.
- Updated protocol decision log with entries for the new skill and its integration.
- Documented two critical JWT logging security issues in `LOGGING_ISSUES.md`.
- Minor: added a cleanup Bash command in `settings.local.json`.
2026-04-25 07:24:16 +02:00
Loretta 806c6e5351 [LOADED_DOCS: 4 files, no new loads]
Refactor docs: topic folders, TOON, XCUT, protocol sync

- Migrated all topic documentation into dedicated folders with canonical `README.md`, `ISSUES.md`, and `TODO.md` per topic (e.g., `LOGGING/`, `SIGNALR/`, `BINARY/`, `TOON/`).
- Added comprehensive TOON serializer documentation: design, format, options, attributes, inference, issues, and TODOs.
- Introduced `XCUT` folder for cross-cutting issues and TODOs, with canonical entries and topic cross-references.
- Updated all references and navigation to use new folder-based doc paths; fixed links and clarified doc structure.
- Enhanced AI agent protocol: enforce session skill preloading, `[LOADED_DOCS: ...]` short-name prefix, and mandatory `docs-check` skill for doc/code sync.
- Updated `.csproj` to include all `README.md` files for IDE visibility.
- Improved and clarified SignalR, grid, and project-level documentation.
- Minor code/test tweaks and doc content corrections for consistency.
2026-04-24 21:54:03 +02:00
Loretta 5d4f078906 [LOADED_DOCS: NONE]
Update protocol, docs-discovery skill, and doc layering

- Switched AI AGENT CORE PROTOCOL to new `[LOADED_DOCS: N files (+K this turn: ...)]` prefix format in all primary instruction files; updated examples and enforcement details.
- Added `docs-discovery` skill for proactive .md doc loading before code search; documented usage and integration as a shared agent skill.
- Introduced `## Protocol History` section and `LLM_PROTOCOL_DECISIONS.md` decision log for cross-repo protocol change tracking.
- Expanded protocol-audit skill and `REPOS.md` to support 8 instruction files (primary/inherit split), new invariants, and known issues.
- Added/updated structured TODO and issues docs for serialization, logging, and SignalR binary protocol.
- Improved cross-references, doc layering, and clarified documentation-first coding workflow.
- Various minor doc clarifications and formatting for protocol consistency.
2026-04-24 08:21:49 +02:00
Loretta 161ef4bba9 Framework-first doctrine, DI logger factory, config refactor
Introduced framework-first design rules and updated documentation to clarify framework vs. consumer boundaries. Added AcLoggerOptions and DI-based logger factory extensions to AyCode.Core, enabling per-category logger instantiation from appsettings.json. Standardized SignalR connection setup with AddAcDefaults, replacing project-specific code. Enhanced protocol configuration for DI scope isolation. Refactored appsettings.json structure and added MSBuild targets for config propagation. Removed obsolete code and updated comments to match new patterns.
2026-04-23 16:11:22 +02:00
Loretta b39d624406 [LOADED_DOCS: .github\copilot-instructions.md, C:\Users\Fullepi\copilot-instructions.md]
Refactor SignalR client DI and config, add test factory

Refactored FruitBankSignalRClient construction to use DI and centralized configuration from appsettings.json across all platforms. Introduced TestSignalRClientFactory for consistent test setup. Added FruitBankHubConnectionExtensions for reusable SignalR connection and logging configuration. Updated Program.cs and MauiProgram.cs to register logger factories, log writers, and IHubConnectionBuilder via DI. Embedded appsettings.json in MAUI and updated .csproj references for build flexibility. No business logic changes; all updates are infrastructure and test setup.
2026-04-22 22:45:32 +02:00
Loretta f1e93f939f Clarify and enforce doc-first, no-redundant-read policy
Correct rule references for no re-reading of loaded `.md` files. Add explicit "STRICT NO-RE-READ POLICY" section defining context and re-read conditions. Expand context recovery rules with auto-detection triggers and specify directories for recovery. These changes make doc-first and anti-loop policies clearer and more robust, ensuring efficient and up-to-date documentation usage.
2026-04-04 20:50:51 +02:00
Loretta bcea6b3205 Strengthen doc-first, multi-repo protocol and clarify tools
Expanded copilot-instructions.md to enforce strict doc-first and cross-repo hard-gate rules, including per-question doc checks and a no re-read policy with explicit penalties. Broadened consent requirements for all file modifications. Updated context recovery paths. In CLAUDE.md, added sequential execution override and mapped Copilot tool names to Claude Code equivalents, ensuring protocol consistency across tools.
2026-04-04 09:27:49 +02:00
Loretta d5fdd57ddd Enforce strict AI agent protocol, doc sync, and glossary
- Added "AI AGENT CORE PROTOCOL" to all copilot-instructions.md files: mandates [LOADED_DOCS] prefix, hard-gates tool usage, enforces no-re-read of .md files, and requires user consent for doc/code changes.
- Updated CLAUDE.md to require reading copilot-instructions.md first.
- Added topic-based doc separation and folder navigation rules.
- Changed doc sync: agent now passively detects discrepancies and asks before updating docs.
- Every code-modifying response must end with a [DOCUMENTATION CHECK] section.
- Centralized measurement system and domain traps in new FruitBank.Common/docs/GLOSSARY.md; updated references in FruitBankHybridApp GLOSSARY.md.
- Clarified schema and doc locations in FruitBankHybridApp README.md.
- Added hybrid execution model section to AyCode.Core BINARY_FEATURES.md.
- Removed unnecessary BeginUpdate/EndUpdate calls in MgGridBase.cs for layout persistence.
- Removed full Toon schema from plugin SCHEMA.md to avoid duplication.
2026-04-02 09:02:54 +02:00
Loretta 045b8e1526 Update LLM instruction files for token efficiency and cross-repo navigation
- CLAUDE.md: reduced to single-line pointer to copilot-instructions.md (eliminates redundant auto-loaded content)
- copilot-instructions.md: added @repo name field, relative paths in own-dep-repos, "do not re-read .md files" rule, and explicit permission to navigate external repos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:15:01 +02:00
Loretta da11df5384 Clarify and expand copilot-instructions conventions
- Clarified DllReference usage for AyCode.Core projects, noting its necessity for build isolation and nopCommerce plugin compatibility.
- Reworded redundancy guideline to stress checking for and reusing existing methods before adding new logic.
- Expanded .md file sync rule: update relevant docs when code changes, resolve contradictions in favor of code, and address shared broken references; during review, suggest (not add) new documentation for useful behaviors.
- Rephrased AyCode.Core solution description for clarity regarding type locations.
2026-03-30 10:15:46 +02:00
Loretta b062c4c712 Add structured metadata and update doc references
- Introduce @repo and @project metadata blocks in copilot-instructions.md and README.md for all projects, declaring type, dependencies, and layer.
- Update all documentation links to reference canonical AyCode.Core and AyCode.Blazor repo locations, removing hardcoded paths.
- Move MgGridBase and related doc references to AyCode.Blazor.Components/docs/ to reflect repo reorg.
- Expand project READMEs with purpose, key files, and dependency tables for clearer architecture.
- Update glossary, conventions, and architecture docs to match new doc structure and reference locations.
- Apply minor corrections to terminology and file references for consistency and accuracy.
- Standardize documentation and make project relationships explicit.
2026-03-30 08:54:33 +02:00
Loretta d1567323d8 Refactor MgGrid documentation into modular files
Split MGGRID.md into focused docs (parameters, CRUD, layout, detail, rendering, InfoPanel, toolbar, columns, datasource) under AyCode.Blazor.Components/docs/. Updated all references, READMEs, and project/solution files to match new structure. Clarified doc layering conventions and core doc links. No code changes—documentation and project organization only.
2026-03-30 08:00:53 +02:00
Loretta 453e21a844 Expand and clarify MgGrid system documentation
Major overhaul of MgGrid-related documentation:
- README.md: Reference full technical docs, update key files list, add new components.
- ARCHITECTURE.md: Add MgGridToolbarTemplate, MgGridSignalRDataSource; clarify component roles.
- GLOSSARY.md: Add comprehensive MgGrid System section with cross-references.
- MGGRID.md: Rewrite overview, hierarchy, CRUD, event wiring, lifecycle, layout, InfoPanel, toolbar, and data column sections for accuracy and completeness.
- copilot-instructions.md: Refine .md sync convention, add documentation layering rule.
- CONVENTIONS.md: Remove redundant code reuse section.

Brings docs in sync with codebase and improves clarity for developers.
2026-03-29 18:29:24 +02:00
Loretta 52131fdf25 Clarify AyCode.Core usage in docs; add MGGRID.md to solution
Expanded documentation in copilot-instructions.md, CLAUDE.md, and ARCHITECTURE.md to clarify that core framework types are defined in AyCode.Core and should be referenced there. Added a "Related Solutions" section and updated the dependency graph. Included docs\MGGRID.md in the solution items list. Improved guidance for locating and using AyCode.Core types.
2026-03-29 10:06:11 +02:00
Loretta 6cce23a124 Add MgGrid system documentation and architecture overview
Added a new section to ARCHITECTURE.md summarizing the MgGrid component system, its hierarchy, and key behaviors, with a link to the new MGGRID.md. Created MGGRID.md with comprehensive documentation on MgGrid design, usage, parameters, lifecycle, events, layout persistence, master-detail, InfoPanel, fullscreen, and public interface. Includes code examples, tables, and diagrams for developer reference.
2026-03-29 10:01:12 +02:00
Loretta b80b117a38 Update docs: enforce .md sync, clarify structure & TFMs
Expanded and clarified solution/project documentation:
- Added all top-level docs and docs/ folder as solution items in .sln files
- Inserted maintenance notices in all project and subfolder READMEs: require .md sync with code
- Main READMEs now include project tables with TFM, purpose, and README links
- ARCHITECTURE.md now details dependency graph and TFM rationale
- CONVENTIONS.md and copilot-instructions.md require code reuse, no redundancy
- Glossary and conventions updated to require terminology/rule updates with code changes
- Emphasized DLL-only AyCode.Core refs and nopCommerce .NET 9.0 requirement
- Clarified domain terms and intentional typos
- No code logic changes; documentation and guidance only
2026-03-29 09:26:15 +02:00
Loretta 79eceff47a Add LLM onboarding docs and standardize project READMEs
- Introduced `.github/copilot-instructions.md` as the single source of truth for domain rules, conventions, and pitfalls in each solution.
- Added `CLAUDE.md` to guide Claude to read domain rules, glossary, and README before code generation.
- Updated all solution and project `README.md` files to document project purpose, structure, key files, and LLM context (Copilot/Claude/Cursor).
- Added or revised `docs/ARCHITECTURE.md`, `docs/CONVENTIONS.md`, and `docs/GLOSSARY.md` to clarify dependency graphs, naming, patterns, and terminology.
- For FruitBankHybridApp, added `docs/SCHEMA.md` (Toon format) and expanded the glossary with business/measurement terms and common traps.
- Updated all subfolder READMEs to list key files, conventions, and LLM maintenance notes.
- Ensured all documentation is cross-referenced, up-to-date, and includes explicit instructions for LLMs to keep docs in sync with code and avoid suggesting removal/rollback as a solution.
- Standardized documentation and onboarding for maintainability and LLM/code quality across all solutions.
2026-03-28 22:38:23 +01:00
Loretta 85dbb853c7 Update ToonTests to serialize type metadata only
Replaced AcToonSerializer.Serialize with SerializeTypeMetadata<FullProcessModel> in ToonTests. The test now serializes only the type metadata for FullProcessModel using the domain description, rather than serializing the actual data instance. Assertions and comments remain unchanged.
2026-03-28 17:25:12 +01:00
Loretta 56475da43b Merge branch 'FruitBank_v0.0.7.0' into FruitBank_v0.0.8.0 2026-03-28 16:21:51 +01:00
Loretta cc2ab55402 Refactor MgGridDataColumn URL templating & update csproj refs
Refactored MgGridDataColumn to efficiently parse and cache URL templates and property accessors, improving cell rendering performance. Replaced Regex.Replace with a compiled, cached approach using [GeneratedRegex]. Updated all project files to use $(Configuration) in DLL HintPaths for correct build output. Added Microsoft.AspNetCore.App framework reference and removed unused references. No breaking API changes.
2026-03-24 18:38:44 +01:00
Loretta 518cfa6865 Refactor measuring UI, centralize status logic
- Reworked MeasuringIn to use tabbed interface with card view and improved form layout
- Unified status badge/text/color logic via new MeasurementService helpers
- Updated MeasuringOut to use centralized status display and improved order note handling
- Added shipping date column to GridShippingDocument
- Improved link styling in MgGridDataColumn
- Removed redundant code and applied minor UI/layout tweaks for consistency and maintainability
2026-03-23 17:43:06 +01:00
Loretta 873ffe91d2 Add scroll-to-item support to MgCardView component
- Introduced `Height`, `ScrollToItem`, and `ItemKeySelector` parameters to MgCardView for scroll targeting and internal scroll area.
- Cards now have stable DOM ids for precise JS-based scrolling.
- Added mgCardView.js with smooth scroll logic; included script in app.
- Updated CSS for scrollable card area.
- Updated MeasuringOut.razor to use new scroll features and fixed time format.
2026-03-23 05:32:15 +01:00
Loretta 40223f9182 Add MgCardView component & refactor MeasuringOut to tabs
Introduced a reusable, responsive MgCardView<TItem> component for displaying data as cards with optional filtering and paging. Refactored the MeasuringOut page to use a tabbed interface: daily tasks are now shown as filterable cards, and measuring details are separated into a dedicated tab. Improved UI clarity, code organization, and maintainability.
2026-03-22 20:01:49 +01:00
Loretta 0da7b67c60 Allow info panel collapse, improve grids, update SignalR
- Enable collapsing of info panel in MgGridWithInfoPanel for better UX
- Refactor GridStockTakingItem columns to use nameof(), clarify bindings, and improve maintainability
- Enhance MeasuringOut order selector with advanced search and clear button
- Update SignalR.Core reference to 9.0.14 in project file
- Minor cleanup of commented group display logic in grid
2026-03-22 16:04:17 +01:00
Loretta ac244fb9fb Enable source-generated binary serialization & AOT
Added AcBinarySerializable and ToonDescription to DTOs/entities for source-generated serialization. Enabled AOT compilation for Blazor/WebAssembly projects. Integrated AyCode.Core.Serializers.SourceGenerator as analyzer. Updated solution and project files, improved entity metadata, and adjusted imports. Commented out InitializeComponent in WinUI App for startup handling.
2026-03-07 14:05:39 +01:00
Loretta 6a95f1cc00 Merge branch 'FruitBank_v0.0.7.0' into FruitBank_v0.0.8.0 2026-03-06 14:51:18 +01:00
Loretta d5a908a46f Update BuildUrlFromTemplate to use Regex with lambda
Refactored BuildUrlFromTemplate to use Regex.Replace with a lambda for dynamic property replacement in template strings. Added a TODO comment about optimizing with cached delegates.
2026-02-23 18:21:20 +01:00
Loretta 9fc12e2fcc Improve toolbar controls and async UX in stock taking
- Added EnableNew and EnableEdit parameters to MgGridToolbarTemplate for finer control of toolbar button states.
- Disabled "New", "Edit", and "Delete" actions in history and item grids by passing explicit parameters.
- Introduced DxLoadingPanel in StockTakingTemplate for better async feedback.
- Disabled "New" and "Close" buttons during async operations to prevent duplicates.
- Enhanced combo boxes with search/filter and usability features.
- Updated new/close stock taking logic to show loading, handle errors, and prevent duplicate actions.
- Set focus to item combo box after saving a StockTakingItemPallet.
- Removed global loading panel from StockTaking.razor; loading is now handled locally.
- Performed minor code cleanups and removed obsolete code.
2026-02-07 08:55:00 +01:00
Loretta 46cafcf382 Merge branch 'FruitBank_v0.0.7.0' into FruitBank_v0.0.8.0 2026-01-12 07:21:47 +01:00
Loretta 7041b795ad Refactor SignalR hub registration and background tasks
- Use Forget() extension for fire-and-forget tasks in MgGridSignalRDataSource
- Remove legacy commented DevAdminSignalRHub code
- Switch to DynamicMethodRegistry for hub method registration, improving startup performance by deferring reflection
- Add clarifying comment for remaining legacy message handling code
2026-01-06 08:55:38 +01:00
Adam e4e279e54c small fixes and android build changes 2026-01-05 09:13:37 +01:00
Loretta b6f51bc2a1 Add LINQ Expression JSON serialization & SignalR grid source
- Introduce AcExpressionHelper and related classes for serializing/deserializing LINQ Expression trees and IQueryable queries to/from JSON, enabling remote transport and execution.
- Add MgGridSignalRDataSource<TDataItem, TId> for instant local filtering and background refresh in DevExpress grids using SignalR.
- Update bunit NuGet package to v2.4.2 in test projects.
- Minor: update FruitBank base URL comment, add new test in OrderClientTests.
2025-12-30 19:29:50 +01:00
Loretta 500e39a514 Enable conditional Delete button in grid toolbars
Add EnableDelete parameter to MgGridToolbarTemplate and set it dynamically in GridShippingDocument and GridShippingItemTemplate based on MeasuringStatus. Import relevant enums, make Grid_CustomizeElement static, and remove checked attribute from NavMenu. Improves safety and user experience for deletions.
2025-12-24 16:58:19 +01:00
Loretta f95602e82d Add AI process modal to shipping grid with toolbar button
Added an "AI process" toolbar button to the shipping document grid, styled with a new magic/sparkles icon. Clicking the button opens a large modal (DxWindow) containing an iframe-based AI processing form (AiProcessFormTemplate). The modal reloads grid data on close. Also includes related CSS for the icon, minor grid layout logic improvements, and retains commented file upload code for reference.
2025-12-23 15:22:52 +01:00
Loretta c468583afd Add user layout management to grids with toolbar actions
- Introduced separate auto-save and user-save layout storage keys
- Added IMgGridBase methods for saving, loading, resetting, and checking user layouts
- Updated grid toolbar with "Layout" menu (load, save, reset) and new icons
- Improved layout persistence logic and default layout restore
- Enabled forced grid re-render on layout reset
- Adjusted grid pager and page size defaults
- Updated related components to use new storage keys
- Fixed minor bugs and set RELEASE log level to Debug
2025-12-23 11:10:19 +01:00
Loretta 4dbeb507fe Add URL link support to grid columns and info panel
- Introduced UrlLink parameter to MgGridDataColumn for clickable cell links with property placeholder support.
- Updated MgGridInfoPanel to render links for columns with UrlLink.
- Added unit tests for UrlLink rendering and value replacement.
- Added DynamicColumnAddingEventArgs and OnDynamicColumnAttributeAdding for dynamic column customization.
- Refactored layout storage key logic and enabled persistent info panel splitter size in MgGridWithInfoPanel.
- Updated product/order grids to use MgGridDataColumn with UrlLink and switched ProductDtos to AcObservableCollection for reactivity.
- Added AddPartner method to IFruitBankDataControllerCommon and FruitBankSignalRClient.
- Miscellaneous fixes: logger initialization, code cleanup, and improved comments.
2025-12-22 14:37:55 +01:00
Loretta bad4e50c17 Add MgLazyLoadContent, grid layout refactor, tests
- Introduced MgLazyLoadContent component with JS observer for lazy rendering of heavy content.
- Refactored MgGridBase for user-specific, customizable layout persistence using JS interop.
- Improved MgGridInfoPanel caching and state batching for performance.
- Updated PDF viewer to use lazy loading for better UX.
- Added AyCode.Blazor.Components.Tests project with bUnit/MSTest grid layout tests.
- Updated solution/project files and removed obsolete code.
- Minor UI and JS module loading improvements.
2025-12-21 16:29:36 +01:00
Loretta 9d682bd741 DevExpress Fluent theme: grid/info panel refactor
Major refactor for DevExpress Fluent theme compatibility:
- InfoPanel templates now use strongly-typed context (`InfoPanelContext`)
- Unified grid layout with `MgGridWithInfoPanel` wrapper
- CSS updated to use Fluent theme variables and container queries
- App-wide CSS cleanup and formatting improvements
- `.editorconfig` added for modern CSS support
- Improved InfoPanel instance resolution for nested grids
- Codebase is cleaner, more maintainable, and ready for further customization
2025-12-21 08:24:40 +01:00
Loretta 057f576375 Improve info panel tables, PDF support, and splash screen
Refactor shipping document info panel tables for better appearance and responsiveness using Bootstrap classes and improved CSS. Add PDF.js and custom PDF viewer scripts to enable PDF rendering in the info panel. Update splash screen handling for better Windows compatibility. Expand allowed Bash commands for development. Ensure UI updates in MgGridBase are properly scheduled with InvokeAsync.
2025-12-20 10:20:51 +01:00
Loretta 15776ca537 Refactor fullscreen grid UI; add serializer diagnostics/tests
- Replaced DxWindow with custom Bootstrap 5 fullscreen overlay for grid components, improving fullscreen UX and styling.
- Added new CSS for fullscreen overlay, header, and body; retained legacy DxWindow styles for compatibility.
- Introduced SignalRSerializerDiagnosticLog flag to control binary serializer diagnostics at runtime.
- Enabled diagnostics in DevAdminSignalRHub, FruitBankSignalRClient, and Program.cs based on the new flag.
- Updated OrderClientTests to use GetStockTakings(false).
- Added StockTakingSerializerTests for binary serialization/deserialization validation and debugging.
2025-12-20 08:40:03 +01:00
Loretta 017eb16c4b Refactor MgGridInfoPanel for theme, UX, and PDF perf
- Refactored MgGridInfoPanel for DevExpress theme compatibility and improved usability; streamlined HTML/CSS, added OnDataItemChanged event, and enhanced empty state handling.
- Updated CSS to use theme variables, improved responsive grid and table styling, and enhanced integration with DevExpress components.
- GridShippingDocumentInfoPanel now uses OnDataItemChanged to load a random PDF per row selection; table layout and totals improved.
- Optimized pdfViewer.js to cache rendered PDFs, skip redundant renders, and improve logging and error handling.
- Added empty helper classes for future extensibility.
- Minor: MainLayout now uses RefreshMainLayout for UI refresh after auto-login.
2025-12-19 13:59:00 +01:00
Loretta 4c86914884 Add fullscreen grid support and PDF preview in info panel
- Added fullscreen mode to grid and info panel components, including toolbar toggle and fullscreen styling.
- Introduced embedded PDF viewing in the info panel using PDF.js and a custom JavaScript viewer.
- Updated interfaces, CSS, and toolbar templates to support new features.
- Added new PDF asset (2_BANK  FRA.pdf) for document preview.
- Minor: Added local settings for Bash permission, fixed text encoding, and improved info panel table layout.
- No code changes in other referenced PDF files; added for informational or asset purposes only.
2025-12-19 07:15:54 +01:00
Loretta 5255917210 Enhance MgGridInfoPanel with responsive/fixed columns
Add support for both responsive and fixed column layouts in MgGridInfoPanel via new parameters (TwoColumnBreakpoint, ThreeColumnBreakpoint, FourColumnBreakpoint, FixedColumnCount). Refactor CSS to use variables for breakpoints, add fixed column classes, and update container queries. Move styles to a new global mg-grid-info-panel.css, referenced in App.razor and index.html. Improve view mode styling and accessibility. Add Partner.Country column to GridPartner.razor.
2025-12-18 11:41:07 +01:00
Loretta 739d0fa808 Refactor: decouple InfoPanel using MgGridWithInfoPanel
Major refactor to decouple InfoPanel logic from grid base. Introduces MgGridWithInfoPanel wrapper component to manage grid and InfoPanel layout and communication. InfoPanels are now customizable via Razor templates with named slots (header, footer, etc.), and grid-to-InfoPanel communication is routed through the wrapper. Removes legacy C#-only InfoPanel base classes and parameters from grid base. This improves flexibility, composability, and maintainability of grid+InfoPanel UIs.
2025-12-18 11:02:53 +01:00
Loretta 112d633590 Add extensible InfoPanel system with custom templates
Introduce a flexible InfoPanel architecture for grid components, allowing per-grid customization via a new InfoPanelType parameter. Add MgInfoPanelTemplateBase for code-based InfoPanel templates with overridable header, content, and footer sections. Support custom templates in MgGridInfoPanel via new RenderFragment parameters. Add MgGridDataColumn for InfoPanel-specific column settings. Demonstrate usage with a custom InfoPanel for ShippingDocument grid. Maintains backward compatibility with default InfoPanel behavior.
2025-12-18 10:03:32 +01:00
Loretta fe1a59a0bd Refactor grid toolbar and InfoPanel for reusability
Introduce reusable MgGridToolbarBase and MgGridToolbarTemplate components, replacing old toolbar implementations. InfoPanel now displays grid captions and includes a toolbar for quick actions. Refactor InfoPanel value rendering for improved DevExpress-like appearance. Update IMgGridBase and related classes to support captions and use new abstractions. Update usings and project structure accordingly.
2025-12-17 18:31:59 +01:00
Loretta 90f12a160e Refactor InfoPanel: non-generic, nested grid support
- Replace generic InfoPanel with non-generic version using IInfoPanelBase
- Add ParentGrid, GetRootGrid, and InfoPanelInstance to IMgGridBase for nested grid hierarchy
- Only root grid displays InfoPanel; nested grids inherit context
- InfoPanel now handles any data type via reflection and object
- All grid-to-InfoPanel communication routed through root grid
- Add option to show/hide readonly fields in edit mode
- Improve InfoPanel CSS for up to 4 responsive columns
- Remove redundant code and add debug output for InfoPanel data flow
2025-12-17 13:54:07 +01:00
Loretta 109a4b82b4 Refactor grid InfoPanel: sticky, responsive, new icons
- Redesigned MgGridInfoPanel to use a sticky, scroll-aware layout via JavaScript for better UX when scrolling.
- InfoPanel now uses a responsive CSS grid layout with container queries for 1/2/3 column display based on width.
- Added new toolbar icons using SVG masks for a modern, consistent look; updated toolbar item class names.
- Added "Prev Row" and "Next Row" navigation buttons to the grid toolbar, with corresponding methods in grid base classes.
- Unified edit state enum naming to MgGridEditState and updated all references.
- Improved InfoPanel cell rendering for better text overflow handling and tooltips.
- Updated CSS for InfoPanel and grid, including sticky pane support and icon styles.
- Registered mgGridInfoPanel.js in App.razor and index.html for JS interop.
- Minor UI/UX tweaks: InfoPanel header, background colors, and panel sizing.
2025-12-17 10:20:17 +01:00
Loretta 45294199cf Refactor grid editing, info panel, and toolbar system
- Introduce MgEditState enum and expose EditState on IMgGridBase
- Replace event-based syncing state with property-based state
- Redesign MgGridInfoPanel to support both view and edit modes with dynamic DevExpress editors and two-way binding
- Add visual distinction for edit/view modes in info panel
- Replace FruitBankToolbarTemplate with generic MgGridToolbarTemplate; toolbar adapts to grid edit/sync state
- Update all grid usages to use new toolbar
- Improve robustness, error handling, and maintainability throughout grid, info panel, and toolbar code
2025-12-17 06:21:21 +01:00
Loretta c1cf30b8f0 Add dynamic Info Panel to grids and update app splash image
Introduced a dynamic Info Panel to MgGridBase, allowing users to view details of the selected row in a side panel. Added the MgGridInfoPanel component with automatic form generation and styling. Updated all grid usages to use the new OnGridFocusedRowChanged event and enabled the info panel in GridPartner. Changed app logo and splash screen references in the Windows packaging manifest and added a placeholder splash image. Also included minor using fixes.
2025-12-16 16:12:38 +01:00
Loretta 920bc299aa Add grid sync state tracking and robust login redirection
Introduce IsSyncing and OnSyncingStateChanged to IMgGridBase and MgGridBase for real-time sync state tracking and event notification. Update FruitBankToolbarTemplate to enable/disable the reload button based on grid sync and reload state, subscribing to sync events and cleaning up on disposal. Implement IAsyncDisposable in MgGridBase to prevent memory leaks. Update login navigation to use forceLoad for reliability. These changes improve UI responsiveness and resource management.
2025-12-09 11:27:21 +01:00
Loretta 06f397e285 Refactor, enhance, and improve test coverage
Refactored `OnDataSourceLoaded` to be asynchronous for better state handling. Downgraded `Newtonsoft.Json` to version `13.0.3` across multiple projects for compatibility. Enhanced `MeasuringItemPalletBase` with `SetParentPropToNull` and `SetForeignKey` methods. Refactored `OrderItemPallet`, `ShippingItemPallet`, and `StockTakingItemPallet` to simplify table attributes and improve parent-child relationship handling.

Added `IsReadyForClose` to `StockTaking` for better closure validation. Updated SignalR tag constants to reflect new functionality. Improved Razor components (`PalletItemComponent`, `StockTakingTemplate`, `MeasuringIn`, `MeasuringOut`) to streamline logic and maintain proper references.

Introduced `JsonExtensionTests` for comprehensive validation of JSON serialization/deserialization, including deep hierarchies, circular references, and hybrid references. Added `test_debug.ps1` for streamlined test debugging. Performed general code cleanup and improved test coverage.
2025-12-08 15:50:57 +01:00
Loretta 0e2d31aa4b .Net10, VS2026; StockTaking in progress... 2025-12-01 16:18:47 +01:00
Loretta 687b745b4d Upgrade to .net10 and Visual Studio 2026; StockTaking in progress... 2025-11-28 08:15:58 +01:00
Loretta 06c266cc78 improvements, fixes 2025-11-26 09:42:16 +01:00
Loretta baaceacdd3 improvements 2025-11-21 16:28:49 +01:00
Loretta 44103b9613 improvements 2025-11-21 07:20:26 +01:00
Loretta dc4321c1d6 improvements, fixes 2025-11-20 08:30:49 +01:00
Loretta 8d4dd5aa90 improvements, fixes, etc... 2025-11-12 17:19:02 +01:00
Loretta 650066becf MgGridBase improvements, fixes 2025-11-08 06:34:18 +01:00
Loretta 935b6abf9a MgGridBase improvements, fixes 2025-11-07 14:10:49 +01:00
Loretta 5e74b93135 improvements 2025-11-06 21:11:46 +01:00
Loretta b7c2e7ea16 Merge branch 'TIAM_v0.0.6.0' into FruitBank_v0.0.7.0 2025-11-06 15:11:20 +01:00
Loretta 9d0ee493ac MgGridBase... 2025-11-06 15:06:10 +01:00
Loretta 7a76138d9a MsBuild targets 2025-11-06 14:40:31 +01:00
Loretta 85032a98fc Add AyCode.Blazor projects; 2025-11-05 15:09:47 +01:00
Loretta 7db379cd81 Remove AdminSignalRHub; etc... 2025-08-31 13:32:45 +02:00
Loretta 35c4d938e3 upgrade to net9.0 2025-08-30 15:52:02 +02:00
Loretta 942bac61fd master to TIAM_v.0.0.6.0 merge 2025-08-30 13:02:25 +02:00
Loretta dca43794c8 Add Microsoft.AspNetCore.SignalR.Core 2025-08-30 12:25:29 +02:00
Loretta 5f661991f9 Devexpress version fixes... 2025-04-08 06:58:55 +02:00
Loretta 6fb3d0d848 Refactoring Login, SignalR, etc... 2024-11-06 14:36:03 +01:00
87 changed files with 7461 additions and 1702 deletions

43
.github/TOPIC_CODES.md vendored Normal file
View File

@ -0,0 +1,43 @@
# Topic Codes — registry for AyCode.Blazor's own topics (`ACBLAZOR`)
Per the Framework-First Design Principle, this Layer 1 registry lists **only AyCode.Blazor's own (`ACBLAZOR`) topics**. Lower-layer (inherited) topics live in their own repos' registries — at runtime, the `docs-check` skill walks `own-dep-repos` from the invocation point to gather all inherited topics. AyCode.Blazor inherits from `AyCode.Core` (see this repo's `@repo.own-dep-repos`).
Full ID format: `<PREFIX>-<TOPIC>-<TYPE>-<RAND>` — see `AyCode.Core/.github/REPO_PREFIXES.md` for the format spec.
## ACBLAZOR topic codes
| Code | Topic | Scope | Docs location |
|---------|-----------------------------|-----------------------------------------------------------------------------------|------------------------------------------------------------------------|
| `GRID` | MGGRID (grid component) | MgGrid component family: layout, CRUD, columns, toolbar, rendering | `AyCode.Blazor.Components/docs/MGGRID/` |
## Type codes (universal — see framework registry)
Type codes (`I`, `T`, `B`, `C`, `DEC`) are universal across all repos and defined in the framework's `TOPIC_CODES.md` (`AyCode.Core/.github/skills/docs-check/references/TOPIC_CODES.md`). This file does not duplicate them.
## ID format rules
See the framework's `TOPIC_CODES.md` (`AyCode.Core/.github/skills/docs-check/references/TOPIC_CODES.md`) for the full ID format rules and Status conventions. This file only registers ACBLAZOR's own topic codes; ACCORE topics are inherited via this repo's `own-dep-repos`.
## Examples (ACBLAZOR only)
```
ACBLAZOR-GRID-T-V4P7 # AyCode.Blazor's MgGrid TODO (e.g., "Generic ID generation in MgGridBase.SetNewId")
ACBLAZOR-GRID-T-S2L9 # AyCode.Blazor's MgGrid TODO (e.g., "Implement local grouping")
ACBLAZOR-GRID-I-XXXX # placeholder for the first MgGrid issue once one is observed
```
## Adding a new ACBLAZOR topic
1. Propose the code (2-5 uppercase chars), short and mnemonic, scoped to AyCode.Blazor's UI-framework domain.
2. Check it doesn't collide with C# class-name prefixes (`Ac*` / `Mg*`).
3. Check it doesn't collide with existing ACBLAZOR topic codes in the table above.
4. Check it doesn't visually collide with framework (`ACCORE-*`) topic codes that this repo references — though the `<PREFIX>` component disambiguates, visual distinction helps readers.
5. Add a row to the table above.
6. Create the topic folder under the relevant project: `AyCode.Blazor.<Project>/docs/{TOPIC_FOLDER_NAME}/` with `README.md`, optional `{TOPIC_FOLDER_NAME}_ISSUES.md`, `{TOPIC_FOLDER_NAME}_TODO.md`.
7. Optional: add an `LLMP-DEC-N` entry in the workspace-level `LLM_PROTOCOL_DECISIONS.md` if the new topic is workspace-meta-significant.
## Cross-references
- **Framework registry** (universal type codes, ID format spec, Status conventions): `AyCode.Core/.github/skills/docs-check/references/TOPIC_CODES.md` (resolved via this repo's `own-dep-repos`).
- **Repo prefix scheme**: `AyCode.Core/.github/REPO_PREFIXES.md`.
- **Decision Log**: `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md`.

192
.github/copilot-instructions.md vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="bunit" Version="2.4.2" />
<PackageReference Include="MSTest" Version="4.0.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Blazor.Components\AyCode.Blazor.Components.csproj" />
<ProjectReference Include="..\..\AyCode.Core\AyCode.Services.Server.Tests\AyCode.Services.Server.Tests.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,25 @@
using Bunit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AyCode.Blazor.Components.Tests;
/// <summary>
/// Base class for bUnit tests using MSTest.
/// Provides BunitContext setup and teardown.
/// </summary>
public abstract class BunitTestContext : TestContextWrapper
{
[TestInitialize]
public void Setup() => Context = new BunitContext();
[TestCleanup]
public void TearDown() => Context?.Dispose();
}
/// <summary>
/// Wrapper for bUnit BunitContext to work with MSTest lifecycle.
/// </summary>
public abstract class TestContextWrapper
{
protected BunitContext? Context { get; set; }
}

View File

@ -0,0 +1,226 @@
using AyCode.Blazor.Components.Components.Grids;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.Tests.SignalRs;
using AyCode.Services.SignalRs;
using Bunit;
using DevExpress.Blazor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AyCode.Blazor.Components.Tests.Grids;
/// <summary>
/// Tests for MgGridBase layout persistence functionality.
/// Tests that column width changes are persisted and loaded correctly.
/// </summary>
[TestClass]
public class MgGridBaseTests : BunitTestContext
{
private TestLogger _logger = null!;
private TestableSignalRHub2 _hub = null!;
private TestableSignalRClient2 _client = null!;
private TestSignalRService2 _service = null!;
private SignalRCrudTags _crudTags = null!;
private const string StorageKey = "TestGrid_Master_AutoSave_0";
[TestInitialize]
public void TestSetup()
{
Context = new BunitContext();
Context.Services.AddDevExpressBlazor();
_logger = new TestLogger();
_hub = new TestableSignalRHub2();
_service = new TestSignalRService2();
_client = new TestableSignalRClient2(_hub, _logger);
_hub.RegisterService(_service, _client);
_crudTags = new SignalRCrudTags(
TestSignalRTags.DataSourceGetAll,
TestSignalRTags.DataSourceGetItem,
TestSignalRTags.DataSourceAdd,
TestSignalRTags.DataSourceUpdate,
TestSignalRTags.DataSourceRemove
);
TestMgGridOrderItem.ClearLayoutStorage();
Context.JSInterop.Mode = JSRuntimeMode.Loose;
}
[TestCleanup]
public void TestTeardown() => Context?.Dispose();
private IRenderedComponent<TestMgGridOrderItem> RenderTestGrid(
string autoSaveLayoutName = "TestGrid",
System.Action<DynamicColumnAddingEventArgs>? onDynamicColumnAttributeAdding = null)
{
var dataSource = new TestGridOrderItemDataSource(_client, _crudTags);
return Context!.Render<TestMgGridOrderItem>(parameters =>
{
parameters
.Add(p => p.DataSource, dataSource)
.Add(p => p.Logger, _logger)
.Add(p => p.SignalRClient, _client)
.Add(p => p.AutoSaveLayoutName, autoSaveLayoutName)
.Add(p => p.GetAllMessageTag, _crudTags.GetAllMessageTag)
.Add(p => p.AddMessageTag, _crudTags.AddMessageTag)
.Add(p => p.UpdateMessageTag, _crudTags.UpdateMessageTag)
.Add(p => p.RemoveMessageTag, _crudTags.RemoveMessageTag);
if (onDynamicColumnAttributeAdding != null)
{
parameters.Add(p => p.OnDynamicColumnAttributeAdding, onDynamicColumnAttributeAdding);
}
});
}
#region Layout Persistence Tests
[TestMethod]
public async Task MgGridBase_ColumnWidth_ShouldBePersisted_WhenNewGridIsRendered()
{
// Arrange - Render first grid
var cut1 = RenderTestGrid();
// Wait for data source to load
await cut1.Instance.WaitForDataSourceLoadedAsync();
// Get columns from first grid
var columns1 = cut1.Instance.GetDataColumns();
Assert.IsTrue(columns1.Count > 0, "Grid should have columns after data source loaded");
// Set column width on first grid (must use BeginUpdate/EndUpdate on the dispatcher)
var firstColumn = columns1[0];
var expectedWidth = "150px";
await cut1.InvokeAsync(() =>
{
cut1.Instance.BeginUpdate();
firstColumn.Width = expectedWidth;
cut1.Instance.EndUpdate();
});
// Trigger layout save by disposing
await cut1.Instance.DisposeAsync();
// Act - Render second grid with same TDataItem
var cut2 = RenderTestGrid();
// Wait for data source to load
await cut2.Instance.WaitForDataSourceLoadedAsync();
// Assert - Second grid should have the same column width
var columns2 = cut2.Instance.GetDataColumns();
Assert.IsTrue(columns2.Count > 0, "Second grid should have columns");
var secondGridFirstColumn = columns2[0];
Assert.AreEqual(expectedWidth, secondGridFirstColumn.Width,
$"Column width should be persisted. Expected: {expectedWidth}, Actual: {secondGridFirstColumn.Width}");
}
[TestMethod]
public async Task MgGridBase_LayoutStorage_ShouldContainLayout_AfterGridRenders()
{
// Arrange & Act
var cut = RenderTestGrid();
await cut.Instance.WaitForDataSourceLoadedAsync();
// Assert
Assert.IsTrue(TestMgGridOrderItem.LayoutStorage.ContainsKey(StorageKey),
"Layout should be saved to storage after grid renders");
Assert.IsNotNull(TestMgGridOrderItem.LayoutStorage[StorageKey]);
}
[TestMethod]
public async Task MgGridBase_DifferentGridNames_ShouldHaveDifferentLayouts()
{
// Arrange - Render first grid with name "Grid1"
var cut1 = RenderTestGrid("Grid1");
await cut1.Instance.WaitForDataSourceLoadedAsync();
// Render second grid with name "Grid2"
var cut2 = RenderTestGrid("Grid2");
await cut2.Instance.WaitForDataSourceLoadedAsync();
// Assert - Different storage keys should exist
var storageKeys = TestMgGridOrderItem.LayoutStorage.Keys.ToList();
// There should be at least 2 different keys
Assert.IsTrue(storageKeys.Count >= 2,
$"Should have at least 2 storage keys, found: {string.Join(", ", storageKeys)}");
// Keys should contain Grid1 and Grid2
Assert.IsTrue(storageKeys.Any(k => k.Contains("Grid1")),
$"Should have Grid1 key. Keys: {string.Join(", ", storageKeys)}");
Assert.IsTrue(storageKeys.Any(k => k.Contains("Grid2")),
$"Should have Grid2 key. Keys: {string.Join(", ", storageKeys)}");
}
[TestMethod]
public async Task MgGridBase_IsMasterGrid_ShouldBeTrueWhenNoParentDataItem()
{
// Act
var cut = RenderTestGrid();
await cut.Instance.WaitForDataSourceLoadedAsync();
// Assert
Assert.IsTrue(cut.Instance.IsMasterGrid);
}
[TestMethod]
public async Task MgGridBase_Columns_ShouldBeBuiltFromReflection()
{
// Arrange & Act
var cut = RenderTestGrid();
await cut.Instance.WaitForDataSourceLoadedAsync();
// Assert - Should have columns for TestOrderItem properties
var columns = cut.Instance.GetDataColumns();
Assert.IsTrue(columns.Count > 0, "Grid should have columns built from TDataItem");
// Verify some expected column names exist
var columnNames = columns.Select(c => c.FieldName).ToList();
Assert.IsTrue(columnNames.Contains(nameof(TestOrderItem.Id)), "Should have Id column");
Assert.IsTrue(columnNames.Contains(nameof(TestOrderItem.ProductName)), "Should have ProductName column");
Assert.IsTrue(columnNames.Contains(nameof(TestOrderItem.Quantity)), "Should have Quantity column");
}
#endregion
#region MgGridDataColumn UrlLink Tests
[TestMethod]
public async Task MgGridDataColumn_UrlLink_ShouldRenderLinkWithReplacedValues()
{
// Arrange - Render grid with UrlLink on Id column
var cut = RenderTestGrid(onDynamicColumnAttributeAdding: args =>
{
if (args.FieldName == nameof(TestOrderItem.Id))
{
args.AdditionalAttributes[nameof(MgGridDataColumn.UrlLink)] = "https://example.com/edit/{Id}";
}
});
// Wait for data source to load
await cut.Instance.WaitForDataSourceLoadedAsync();
// Get the first row's Id value from the grid
var firstRowId = cut.Instance.GetRowValue(0, nameof(TestOrderItem.Id));
Assert.IsNotNull(firstRowId, "First row should have an Id value");
// Build the expected URL with the actual Id value
var expectedUrl = $"https://example.com/edit/{firstRowId}";
// Find the anchor element with the exact expected href
var anchor = cut.Find($"a[href=\"{expectedUrl}\"]");
// Assert - The anchor should exist and its text content should be the Id value
Assert.IsNotNull(anchor, $"Should find anchor with href='{expectedUrl}'");
Assert.AreEqual(firstRowId.ToString(), anchor.TextContent,
$"Anchor text should be the Id value '{firstRowId}', but was '{anchor.TextContent}'");
}
#endregion
}

View File

@ -0,0 +1,17 @@
# Grids
Grid component integration tests for `MgGridBase` layout persistence, column rendering, and URL link functionality.
## Key Files
- **`TestMgGrid.cs`** -- Test infrastructure for grid testing:
- `TestGridOrderItemDataSource` -- DataSource with the 3-parameter constructor required by `MgGridBase.OnInitializedAsync` via `Activator.CreateInstance`.
- `DynamicColumnAddingEventArgs` -- Event args for customizing dynamically added columns.
- `TestMgGridBase<...>` -- Abstract generic test grid that overrides layout persistence with in-memory `Dictionary` storage and auto-builds columns from `TDataItem` properties via reflection.
- `TestMgGridOrderItem` -- Concrete test grid bound to `TestOrderItem` entities.
- **`MgGridBaseTests.cs`** -- `[TestClass]` with tests for:
- Column width persistence across grid re-renders.
- Layout storage population after render.
- Separate layout keys for differently named grids.
- Master grid detection.
- Reflection-based column building (verifies Id, ProductName, Quantity columns).
- `MgGridDataColumn.UrlLink` rendering with token replacement.

View File

@ -0,0 +1,202 @@
using AyCode.Blazor.Components.Components.Grids;
using AyCode.Core.Helpers;
using AyCode.Core.Interfaces;
using AyCode.Core.Loggers;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.Server.Tests.SignalRs;
using AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
using AyCode.Services.SignalRs;
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using System.Reflection;
using Microsoft.AspNetCore.Components.Rendering;
namespace AyCode.Blazor.Components.Tests.Grids;
/// <summary>
/// Test DataSource that extends TestOrderItemObservableDataSource with the 3-parameter constructor
/// required by MgGridBase.OnInitializedAsync which uses Activator.CreateInstance.
/// </summary>
public class TestGridOrderItemDataSource : TestOrderItemObservableDataSource
{
public TestGridOrderItemDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags)
: base(signalRClient, crudTags) { }
public TestGridOrderItemDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags, params object[]? contextIds)
: base(signalRClient, crudTags)
{
}
}
/// <summary>
/// Event args for dynamic column adding event.
/// Provides a delegate to add custom attributes to the column.
/// </summary>
public class DynamicColumnAddingEventArgs
{
public required string FieldName { get; init; }
public required PropertyInfo PropertyInfo { get; init; }
/// <summary>
/// Dictionary of additional attributes to add to the column.
/// Key is the attribute name, value is the attribute value.
/// </summary>
public Dictionary<string, object?> AdditionalAttributes { get; } = new();
}
/// <summary>
/// Base test implementation of MgGridBase for testing grid functionality.
/// Overrides layout persistence to use in-memory storage for testing.
/// Automatically builds columns from TDataItem properties using reflection.
/// </summary>
public abstract class TestMgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>
: MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>
where TSignalRDataSource : AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>>
where TDataItem : class, IId<TId>
where TId : struct
where TLoggerClient : AcLoggerBase
{
private int _testUserId;
private bool _columnsInitialized;
/// <summary>
/// In-memory storage for layout persistence testing.
/// Shared across all instances to simulate localStorage behavior.
/// </summary>
public static Dictionary<string, GridPersistentLayout?> LayoutStorage { get; } = new();
/// <summary>
/// Indicates whether data source has been loaded
/// </summary>
public bool IsDataSourceLoaded { get; private set; }
/// <summary>
/// Event called when a dynamic column is being added. Allows customization of column properties.
/// Add attributes to eventArgs.AdditionalAttributes dictionary.
/// </summary>
[Parameter]
public Action<DynamicColumnAddingEventArgs>? OnDynamicColumnAttributeAdding { get; set; }
public void SetTestUserId(int userId) => _testUserId = userId;
public int GetTestUserId() => _testUserId;
protected override int GetLayoutUserId() => _testUserId;
public static void ClearLayoutStorage() => LayoutStorage.Clear();
protected override Task<GridPersistentLayout?> LoadLayoutFromLocalStorageAsync(string localStorageKey)
{
LayoutStorage.TryGetValue(localStorageKey, out var layout);
return Task.FromResult(layout);
}
protected override Task SaveLayoutToLocalStorageAsync(GridPersistentLayout layout, string localStorageKey)
{
LayoutStorage[localStorageKey] = layout;
return Task.CompletedTask;
}
/// <summary>
/// Waits for the data source to be loaded using TaskHelper
/// </summary>
public Task<bool> WaitForDataSourceLoadedAsync(int timeoutMs = 5000)
{
return TaskHelper.WaitToAsync(() => IsDataSourceLoaded, timeoutMs);
}
protected override void OnParametersSet()
{
if (!_columnsInitialized)
{
// Build columns from TDataItem properties using reflection
Columns = BuildColumnsFromDataItem;
_columnsInitialized = true;
}
base.OnParametersSet();
// Subscribe to OnDataSourceChanged to know when data is loaded
OnDataSourceChanged = EventCallback.Factory.Create<IList<TDataItem>>(this, _ =>
{
IsDataSourceLoaded = true;
});
}
/// <summary>
/// Builds grid columns from TDataItem properties using reflection
/// </summary>
private void BuildColumnsFromDataItem(RenderTreeBuilder builder)
{
var properties = typeof(TDataItem).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && IsSimpleType(p.PropertyType))
.ToList();
var seq = 0;
foreach (var property in properties)
{
// Create event args and invoke the event
var eventArgs = new DynamicColumnAddingEventArgs
{
FieldName = property.Name,
PropertyInfo = property
};
OnDynamicColumnAttributeAdding?.Invoke(eventArgs);
builder.OpenComponent<MgGridDataColumn>(seq++);
builder.AddAttribute(seq++, nameof(MgGridDataColumn.Name), property.Name);
builder.AddAttribute(seq++, nameof(MgGridDataColumn.FieldName), property.Name);
builder.AddAttribute(seq++, nameof(MgGridDataColumn.Width), GetDefaultWidth(property.PropertyType));
// Add additional attributes from the event
foreach (var attr in eventArgs.AdditionalAttributes)
{
builder.AddAttribute(seq++, attr.Key, attr.Value);
}
builder.CloseComponent();
}
}
/// <summary>
/// Determines if a type is a simple type suitable for grid display
/// </summary>
private static bool IsSimpleType(Type type)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
return underlyingType.IsPrimitive
|| underlyingType == typeof(string)
|| underlyingType == typeof(decimal)
|| underlyingType == typeof(DateTime)
|| underlyingType == typeof(DateTimeOffset)
|| underlyingType == typeof(TimeSpan)
|| underlyingType == typeof(Guid)
|| underlyingType.IsEnum;
}
/// <summary>
/// Gets default column width based on property type
/// </summary>
private static string GetDefaultWidth(Type type)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
return underlyingType switch
{
_ when underlyingType == typeof(int) || underlyingType == typeof(long) => "80px",
_ when underlyingType == typeof(decimal) || underlyingType == typeof(double) || underlyingType == typeof(float) => "100px",
_ when underlyingType == typeof(bool) => "60px",
_ when underlyingType == typeof(DateTime) || underlyingType == typeof(DateTimeOffset) => "150px",
_ when underlyingType == typeof(string) => "200px",
_ when underlyingType == typeof(Guid) => "250px",
_ => "120px"
};
}
}
/// <summary>
/// Test grid for TestOrderItem entities
/// </summary>
public class TestMgGridOrderItem : TestMgGridBase<TestGridOrderItemDataSource, TestOrderItem, int, TestLogger>
{
}

View File

@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

View File

@ -0,0 +1,21 @@
# AyCode.Blazor.Components.Tests
@project {
type = "test"
}
bUnit + MSTest component test project targeting net10.0. Tests Blazor component behavior including grid layout persistence and column rendering.
## Key Files
- **`MSTestSettings.cs`** -- Assembly-level config enabling method-level test parallelization.
- **`BunitTestContext.cs`** -- Abstract base classes (`BunitTestContext`, `TestContextWrapper`) that wire up bUnit `BunitContext` creation and disposal to MSTest `[TestInitialize]`/`[TestCleanup]` lifecycle.
- **`Grids/`** -- Grid component integration tests (see [Grids/README.md](Grids/README.md)).
## Dependencies
| Dependency | Version | Type |
|---|---|---|
| bunit | 2.4.2 | NuGet |
| MSTest | 4.0.2 | NuGet |
| AyCode.Blazor.Components | -- | ProjectReference |
| AyCode.Services.Server.Tests | -- | ProjectReference |

View File

@ -0,0 +1,11 @@
root = true
# CSS files
[*.css]
# Disable warnings for modern CSS features
css_disable_validation_warnings = true
css_schema_version = css3
# Allow container queries and modern CSS
css_container_queries = true
css_custom_properties = true

View File

@ -1,53 +1,68 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<RunAOTCompilation>true</RunAOTCompilation>
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
</PropertyGroup>
<Import Project="..//AyCode.Blazor.targets" />
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Blazor" Version="24.1.3" />
<PackageReference Include="DevExpress.Blazor.Dashboard" Version="24.1.3" />
<PackageReference Include="MessagePack" Version="2.5.187" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Blazor" Version="25.1.3" />
<PackageReference Include="DevExpress.Data" Version="25.1.3" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Core" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
<ItemGroup>
<Folder Include="Layouts\" />
<Folder Include="Pages\" />
</ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Folder Include="Layouts\" />
<Folder Include="Pages\" />
<Folder Include="Services\Logins\" />
</ItemGroup>
<ItemGroup>
<None Include="docs\**\*.md" />
<None Include="**\README.md" Exclude="$(DefaultItemExcludes);docs\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj" />
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
</ItemGroup>
</Project>

View File

@ -1,53 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Blazor" Version="23.2.3" />
<PackageReference Include="DevExpress.Blazor.Dashboard" Version="23.2.3" />
<PackageReference Include="MessagePack" Version="2.5.168" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.6" />
</ItemGroup>
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Folder Include="Layouts\" />
<Folder Include="Pages\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,36 @@
@typeparam TItem
<div class="mg-card-view-container" style="@ContainerStyle">
@if (ShowFilterPanel && FilterPanel is not null)
{
<div class="mg-card-filter-panel">
@FilterPanel
</div>
}
@if (Data is { Count: > 0 })
{
<div class="mg-card-scroll-area">
<div class="mg-card-grid @CssClass"
style="--cols-xs: @ColumnCountXs; --cols-sm: @ColumnCountSm; --cols-lg: @ColumnCountLg;">
@foreach (var item in PagedItems)
{
<div id="@GetCardElementId(item)"
class="mg-card @CardCssClass"
@onclick="() => OnCardClickInternal(item)"
style="@(OnCardClick.HasDelegate ? "cursor: pointer;" : "")">
@CardTemplate(item)
</div>
}
</div>
</div>
@if (ShowPager && Data.Count > PageSize)
{
<DxPager PageCount="@((int)Math.Ceiling((double)Data.Count / PageSize))"
ActivePageIndex="_activePageIndex"
ActivePageIndexChanged="OnActivePageIndexChanged"
CssClass="mt-2" />
}
}
</div>

View File

@ -0,0 +1,163 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace AyCode.Blazor.Components.Components.CardViews;
/// <summary>
/// Generic card view component that displays items in a responsive CSS Grid layout
/// with optional pagination and scroll-to-item support.
/// </summary>
/// <typeparam name="TItem">The type of data item displayed in each card.</typeparam>
public partial class MgCardView<TItem> : ComponentBase
{
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
/// <summary>
/// The collection of items to display as cards.
/// </summary>
[Parameter, EditorRequired]
public IReadOnlyList<TItem> Data { get; set; } = [];
/// <summary>
/// Template for rendering each card's content.
/// </summary>
[Parameter, EditorRequired]
public RenderFragment<TItem> CardTemplate { get; set; } = null!;
/// <summary>
/// Fired when a card is clicked/tapped.
/// </summary>
[Parameter]
public EventCallback<TItem> OnCardClick { get; set; }
/// <summary>
/// Number of columns on extra-small screens (below 576px). Default: 1.
/// </summary>
[Parameter]
public int ColumnCountXs { get; set; } = 1;
/// <summary>
/// Number of columns on small screens (576768px). Default: 2.
/// </summary>
[Parameter]
public int ColumnCountSm { get; set; } = 2;
/// <summary>
/// Number of columns on medium+ screens (769px+). Default: 3.
/// </summary>
[Parameter]
public int ColumnCountLg { get; set; } = 3;
/// <summary>
/// Whether to show the pager below the cards. Default: false.
/// </summary>
[Parameter]
public bool ShowPager { get; set; }
/// <summary>
/// Number of items per page when paging is enabled. Default: 12.
/// </summary>
[Parameter]
public int PageSize { get; set; } = 12;
/// <summary>
/// Additional CSS class for the card view container.
/// </summary>
[Parameter]
public string? CssClass { get; set; }
/// <summary>
/// Additional CSS class applied to each individual card wrapper.
/// </summary>
[Parameter]
public string? CardCssClass { get; set; }
/// <summary>
/// Height of the card view container (e.g., "500px", "70vh"). When set, the component uses its own scroll area.
/// </summary>
[Parameter]
public string? Height { get; set; }
/// <summary>
/// Whether to show the filter panel above the cards. Default: false.
/// </summary>
[Parameter]
public bool ShowFilterPanel { get; set; }
/// <summary>
/// Custom content for the filter panel. Rendered above the card grid when ShowFilterPanel is true.
/// </summary>
[Parameter]
public RenderFragment? FilterPanel { get; set; }
/// <summary>
/// Item to scroll into view after render. Set to null to disable.
/// </summary>
[Parameter]
public TItem? ScrollToItem { get; set; }
/// <summary>
/// Key selector for identifying items (e.g., item => item.Id). Required when ScrollToItem is used.
/// </summary>
[Parameter]
public Func<TItem, object>? ItemKeySelector { get; set; }
private int _activePageIndex;
private object? _lastScrolledKey;
private string? ContainerStyle => Height is not null ? $"height: {Height};" : null;
private IReadOnlyList<TItem> PagedItems
{
get
{
if (!ShowPager)
return Data;
return Data
.Skip(_activePageIndex * PageSize)
.Take(PageSize)
.ToList();
}
}
private async Task OnCardClickInternal(TItem item)
{
if (OnCardClick.HasDelegate)
await OnCardClick.InvokeAsync(item);
}
private void OnActivePageIndexChanged(int newPageIndex)
{
_activePageIndex = newPageIndex;
}
/// <summary>
/// Generates a stable DOM element id for a card item using the key selector.
/// </summary>
private string? GetCardElementId(TItem item)
{
return ItemKeySelector is null ? null : $"mg-card-{ItemKeySelector(item)}";
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ScrollToItem is not null && ItemKeySelector is not null)
{
var key = ItemKeySelector(ScrollToItem);
if (!Equals(key, _lastScrolledKey))
{
_lastScrolledKey = key;
var elementId = $"mg-card-{key}";
try
{
await JSRuntime.InvokeVoidAsync("MgCardView.scrollToElement", elementId);
}
catch (JSException)
{
// JS might not be loaded yet
}
}
}
}
}

View File

@ -0,0 +1,52 @@
.mg-card-view-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
.mg-card-scroll-area {
flex: 1;
overflow-y: auto;
padding-right: 4px;
}
.mg-card-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(var(--cols-xs, 1), 1fr);
}
@media (min-width: 576px) {
.mg-card-grid {
grid-template-columns: repeat(var(--cols-sm, 2), 1fr);
}
}
@media (min-width: 769px) {
.mg-card-grid {
grid-template-columns: repeat(var(--cols-lg, 3), 1fr);
}
}
.mg-card {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 16px;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease, transform 0.15s ease;
height: 100%;
}
.mg-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.mg-card-filter-panel {
margin-bottom: 12px;
padding: 12px 16px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
}

View File

@ -0,0 +1,7 @@
# CardViews
Generic card-based view component that renders items in a responsive CSS Grid layout with optional pagination, filtering, and scroll-to-item support.
## Key Files
- **`MgCardView.razor.cs`** -- `MgCardView<TItem>` component. Accepts `Data`, `CardTemplate`, responsive column counts (`ColumnCountXs/Sm/Lg`), optional pager (`ShowPager`, `PageSize` default 12), optional filter panel, container height, and `ScrollToItem` with JS interop for auto-scrolling. Fires `OnCardClick` when a card is tapped.

View File

@ -0,0 +1,22 @@
namespace AyCode.Blazor.Components.Components.Grids;
/// <summary>
/// Represents the current edit state of the MgGrid
/// </summary>
public enum MgGridEditState
{
/// <summary>
/// No edit operation in progress
/// </summary>
None,
/// <summary>
/// Adding a new row
/// </summary>
New,
/// <summary>
/// Editing an existing row
/// </summary>
Edit
}

View File

@ -0,0 +1,982 @@
using AyCode.Core;
using AyCode.Core.Enums;
using AyCode.Core.Helpers;
using AyCode.Core.Interfaces;
using AyCode.Core.Loggers;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
using AyCode.Utils.Extensions;
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.JSInterop;
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using DevExpress.Blazor.Internal;
using System.Text.RegularExpressions;
using AyCode.Core.Compression;
namespace AyCode.Blazor.Components.Components.Grids;
public interface IMgGridBase : IGrid
{
/// <summary>
/// Indicates whether any synchronization operation is in progress
/// </summary>
bool IsSyncing { get; }
string Caption { get; set; }
/// <summary>
/// Current edit state of the grid (None, New, Edit)
/// </summary>
MgGridEditState GridEditState { get; }
/// <summary>
/// Parent grid in nested grid hierarchy (null if this is a root grid)
/// </summary>
IMgGridBase? ParentGrid { get; }
/// <summary>
/// Gets the root grid in the hierarchy
/// </summary>
IMgGridBase GetRootGrid();
/// <summary>
/// Navigates to the previous row in the grid
/// </summary>
void StepPrevRow();
/// <summary>
/// Navigates to the next row in the grid
/// </summary>
void StepNextRow();
/// <summary>
/// InfoPanel instance for displaying row details (from wrapper)
/// </summary>
IInfoPanelBase? InfoPanelInstance { get; }
/// <summary>
/// Whether the grid/wrapper is currently in fullscreen mode
/// </summary>
bool IsFullscreen { get; }
/// <summary>
/// Storage key for automatic layout persistence
/// </summary>
string AutomaticLayoutStorageKey { get; }
/// <summary>
/// Toggles fullscreen mode for the grid (or wrapper if available)
/// </summary>
void ToggleFullscreen();
/// <summary>
/// Saves the current layout to user storage (manual save)
/// </summary>
Task SaveUserLayoutAsync();
/// <summary>
/// Loads layout from user storage (manual load)
/// </summary>
Task LoadUserLayoutAsync();
/// <summary>
/// Resets the layout by clearing auto-saved layout and reloading the page
/// </summary>
Task ResetLayoutAsync();
/// <summary>
/// Checks if a user-saved layout exists without loading it
/// </summary>
Task<bool> HasUserLayoutAsync();
}
public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient> : DxGrid, IMgGridBase, IAsyncDisposable
where TSignalRDataSource : AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>>
where TDataItem : class, IId<TId>
where TId : struct
where TLoggerClient : AcLoggerBase
{
private readonly EqualityComparer<TId> _equalityComparerId = EqualityComparer<TId>.Default;
private readonly TypeConverter _typeConverterId = TypeDescriptor.GetConverter(typeof(TId));
protected bool IsFirstInitializeParameters;
protected bool IsFirstInitializeParameterCore;
private bool _isDisposed;
private Guid _gridRenderKey = Guid.NewGuid();
private TSignalRDataSource? _dataSource = null!;
private AcObservableCollection<TDataItem>? _dataSourceParam = [];
private string _gridLogName;
/// <inheritdoc />
public bool IsSyncing => _dataSource?.IsSyncing ?? false;
/// <inheritdoc />
public MgGridEditState GridEditState { get; private set; } = MgGridEditState.None;
/// <inheritdoc />
[CascadingParameter]
public IMgGridBase? ParentGrid { get; set; }
/// <inheritdoc />
public IMgGridBase GetRootGrid()
{
var current = (IMgGridBase)this;
while (current.ParentGrid != null)
{
current = current.ParentGrid;
}
return current;
}
/// <summary>
/// Gets the user layout storage key (replaces AutoSave with UserSave)
/// </summary>
private string UserLayoutStorageKey => AutomaticLayoutStorageKey.Replace("_AutoSave_", "_UserSave_");
public string AutomaticLayoutStorageKey
{
get
{
var masterDetailName = IsMasterGrid ? "Master" : ParentDataItem!.GetType().Name;
return $"{AutoSaveLayoutName}_{masterDetailName}_AutoSave_{GetLayoutUserId()}";
}
}
/// <summary>
/// Reference to the wrapper component for grid-InfoPanel communication
/// </summary>
[CascadingParameter]
public MgGridWithInfoPanel? GridWrapper { get; set; }
private object _focusedDataItem;
/// <summary>
/// InfoPanel instance for displaying row details.
/// First checks own wrapper, then gets InfoPanel from root grid.
/// </summary>
public IInfoPanelBase? InfoPanelInstance
{
get
{
// First check if we have a direct wrapper with InfoPanel
if (GridWrapper?.InfoPanelInstance != null)
return GridWrapper.InfoPanelInstance;
// Get InfoPanel from root grid (handles nested grids)
var rootGrid = GetRootGrid();
if (rootGrid != this)
return rootGrid.InfoPanelInstance;
return null;
}
}
/// <inheritdoc />
public bool IsFullscreen => GridWrapper?.IsFullscreen ?? _isStandaloneFullscreen;
private bool _isStandaloneFullscreen;
/// <inheritdoc />
public void ToggleFullscreen()
{
if (GridWrapper != null)
{
// Ha van wrapper, azt váltjuk fullscreen-be
GridWrapper.ToggleFullscreen();
}
else
{
// Ha nincs wrapper, saját fullscreen állapotot használunk
_isStandaloneFullscreen = !_isStandaloneFullscreen;
InvokeAsync(StateHasChanged);
}
}
public MgGridBase() : base()
{
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
var seq = 0;
// Wrap everything in a CascadingValue to provide this grid as ParentGrid to nested grids
builder.OpenComponent<CascadingValue<IMgGridBase>>(seq++);
builder.AddAttribute(seq++, "Value", (IMgGridBase)this);
builder.AddAttribute(seq++, "ChildContent", (RenderFragment)(contentBuilder =>
{
if (_isStandaloneFullscreen && GridWrapper == null)
{
// Standalone fullscreen mode - Bootstrap 5 fullscreen overlay
contentBuilder.OpenElement(0, "div");
contentBuilder.AddAttribute(1, "class", "mg-fullscreen-overlay");
contentBuilder.SetKey(_gridRenderKey);
// Header
contentBuilder.OpenElement(2, "div");
contentBuilder.AddAttribute(3, "class", "mg-fullscreen-header");
contentBuilder.OpenElement(4, "span");
contentBuilder.AddAttribute(5, "class", "mg-fullscreen-title");
contentBuilder.AddContent(6, Caption);
contentBuilder.CloseElement(); // span
contentBuilder.OpenElement(7, "button");
contentBuilder.AddAttribute(8, "type", "button");
contentBuilder.AddAttribute(9, "class", "btn-close btn-close-white");
contentBuilder.AddAttribute(10, "aria-label", "Close");
contentBuilder.AddAttribute(11, "onclick", EventCallback.Factory.Create<Microsoft.AspNetCore.Components.Web.MouseEventArgs>(this, () =>
{
_isStandaloneFullscreen = false;
InvokeAsync(StateHasChanged);
}));
contentBuilder.CloseElement(); // button
contentBuilder.CloseElement(); // header div
// Body
contentBuilder.OpenElement(12, "div");
contentBuilder.AddAttribute(13, "class", "mg-fullscreen-body");
base.BuildRenderTree(contentBuilder);
contentBuilder.CloseElement(); // body div
contentBuilder.CloseElement(); // overlay div
}
else
{
// Normal mode - use key for forced re-render on reset
contentBuilder.OpenElement(0, "div");
contentBuilder.SetKey(_gridRenderKey);
contentBuilder.AddAttribute(1, "style", "display: contents;");
base.BuildRenderTree(contentBuilder);
contentBuilder.CloseElement();
}
}));
builder.CloseComponent();
}
protected bool HasIdValue(TDataItem dataItem) => HasIdValue(dataItem.Id);
protected bool HasIdValue(TId id) => !_equalityComparerId.Equals(id, default);
protected bool IdEquals(TId id1, TId id2) => _equalityComparerId.Equals(id1, id2);
[Inject] protected IJSRuntime JSRuntime { get; set; } = null!;
[Parameter] public TLoggerClient Logger { get; set; }
[Parameter] public string GridName { get; set; }
[Parameter] public IId<TId>? ParentDataItem { get; set; }
[Parameter] public string? KeyFieldNameToParentId { get; set; }
[Parameter] public object[]? ContextIds { get; set; }
[Parameter] public string Caption { get; set; } = typeof(TDataItem).Name;
/// <summary>
/// Name for auto-saving/loading grid layout. If not set, defaults to "Grid{TDataItem.Name}"
/// </summary>
[Parameter] public string? AutoSaveLayoutName { get; set; }
public bool IsMasterGrid => ParentDataItem == null;
protected PropertyInfo? KeyFieldPropertyInfoToParent;
private string? _filterText = null;
[Parameter]
public string? FilterText
{
get => _filterText;
set
{
_filterText = value;
if (_dataSource != null && _dataSource.FilterText != value)
{
_dataSource.FilterText = value;
ReloadDataSourceAsync().Forget();
}
}
}
[Parameter] public AcSignalRClientBase SignalRClient { get; set; }
[Parameter] public int GetAllMessageTag { get; set; }
[Parameter] public int GetItemMessageTag { get; set; }
[Parameter] public int AddMessageTag { get; set; }
[Parameter] public int UpdateMessageTag { get; set; }
[Parameter] public int RemoveMessageTag { get; set; }
protected new EventCallback<GridDataItemDeletingEventArgs> DataItemDeleting { get; set; }
[Parameter] public EventCallback<GridDataItemDeletingEventArgs> OnGridItemDeleting { get; set; }
protected new EventCallback<GridEditModelSavingEventArgs> EditModelSaving { get; set; }
[Parameter] public EventCallback<GridEditModelSavingEventArgs> OnGridEditModelSaving { get; set; }
protected new EventCallback<GridEditStartEventArgs> EditStart { get; set; }
[Parameter] public EventCallback<GridEditStartEventArgs> OnGridEditStart { get; set; }
protected new EventCallback<GridCustomizeEditModelEventArgs> CustomizeEditModel { get; set; }
[Parameter] public EventCallback<GridCustomizeEditModelEventArgs> OnGridCustomizeEditModel { get; set; }
protected new EventCallback<GridFocusedRowChangedEventArgs> FocusedRowChanged { get; set; }
[Parameter] public EventCallback<GridFocusedRowChangedEventArgs> OnGridFocusedRowChanged { get; set; }
[Parameter] public EventCallback<IList<TDataItem>> OnDataSourceChanged { get; set; }
[Parameter] public EventCallback<GridDataItemChangingEventArgs<TDataItem>> OnGridItemChanging { get; set; }
/// <summary>
/// After the server has responded!
/// </summary>
[Parameter]
public EventCallback<GridDataItemChangedEventArgs<TDataItem>> OnGridItemChanged { get; set; }
[Parameter]
[DefaultValue(null)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "BL0007:Component parameters should be auto properties", Justification = "<Pending>")]
public IList<TDataItem> DataSource
{
get
{
if (_dataSource == null && Data != null)
{
Logger.Error($"{_gridLogName} Use the DataSource parameter instead of Data!");
throw new NullReferenceException($"{_gridLogName} Use the DataSource parameter instead of Data!");
}
return _dataSource!;
}
set
{
_dataSourceParam = value as AcObservableCollection<TDataItem>;
if (_dataSource != null) // && _dataSourceParam is List<TDataItem> workingReferenceList)
{
SetWorkingReferenceList(_dataSourceParam);
}
}
}
private void SetWorkingReferenceList(AcObservableCollection<TDataItem>? referenceList)
{
_dataSource?.SetWorkingReferenceList(referenceList);
SetGridData(referenceList);
}
public void SetGridData(object? data)
{
if (_isDisposed) return;
if (ReferenceEquals(Data, data)) return;
BeginUpdate();
Data = data;
EndUpdate();
}
protected override async Task OnInitializedAsync()
{
if (Logger == null)
throw new NullReferenceException($"[{GetType().Name}] Logger == null");
if (SignalRClient == null)
{
Logger.Error($"[{GetType().Name}] SignalRClient == null");
throw new NullReferenceException($"[{GetType().Name}] SignalRClient == null");
}
var crudTags = new SignalRCrudTags(GetAllMessageTag, GetItemMessageTag, AddMessageTag, UpdateMessageTag, RemoveMessageTag);
_dataSource = (TSignalRDataSource)Activator.CreateInstance(typeof(TSignalRDataSource), SignalRClient, crudTags, ContextIds)!;
_dataSource.FilterText = FilterText;
SetGridData(_dataSource.GetReferenceInnerList());
_dataSource.OnDataSourceLoaded += OnDataSourceLoaded;
_dataSource.OnDataSourceItemChanged += OnDataSourceItemChanged;
_dataSource.OnSyncingStateChanged += OnDataSourceSyncingStateChanged;
await base.OnInitializedAsync();
}
private void OnDataSourceSyncingStateChanged(bool isSyncing)
{
if (_isDisposed) return;
// Forward the event to external subscribers
//OnSyncingStateChanged?.Invoke(isSyncing);
// Trigger UI update
InvokeAsync(StateHasChanged);
}
private async Task OnDataSourceItemChanged(ItemChangedEventArgs<TDataItem> args)
{
if (_isDisposed) return;
if (args.TrackingState is TrackingState.GetAll or TrackingState.None) return;
Logger.Debug($"{_gridLogName} OnDataSourceItemChanged; trackingState: {args.TrackingState}");
var changedEventArgs = new GridDataItemChangedEventArgs<TDataItem>(this, args.Item, args.TrackingState);
await OnGridItemChanged.InvokeAsync(changedEventArgs);
if (!changedEventArgs.CancelStateChangeInvoke && !_isDisposed)
{
await InvokeAsync(StateHasChanged);
}
}
private async Task OnDataSourceLoaded()
{
if (_isDisposed) return;
Logger.Debug($"{_gridLogName} OnDataSourceLoaded; Count: {_dataSource?.Count}");
await InvokeAsync(() => SetGridData(_dataSource!.GetReferenceInnerList()));
if (!_isDisposed)
{
await OnDataSourceChanged.InvokeAsync(_dataSource);
await InvokeAsync(StateHasChanged);
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (_dataSource == null) return;
if (_dataSourceParam != null) await _dataSource.LoadDataSource(_dataSourceParam, true, true);
else _dataSource.LoadDataSourceAsync(true).Forget();
}
await base.OnAfterRenderAsync(firstRender);
}
private void SetNewId(TDataItem dataItem)
{
//TODO: int !!! - J.
if (dataItem.Id is Guid)
{
dataItem.Id = (TId)(_typeConverterId.ConvertTo(Guid.NewGuid(), typeof(TId)))!;
}
else if (dataItem.Id is int)
{
var newId = -1 * AcDomain.NextUniqueInt32;
dataItem.Id = (TId)(_typeConverterId.ConvertTo(newId, typeof(TId)))!;
}
}
public Task AddDataItem(TDataItem dataItem)
{
if (!HasIdValue(dataItem)) SetNewId(dataItem);
return _dataSource.Add(dataItem, true);
}
public Task AddDataItemAsync(TDataItem dataItem)
{
if (!HasIdValue(dataItem)) SetNewId(dataItem);
_dataSource.Add(dataItem);
return SaveChangesToServerAsync();
}
public Task InsertDataItem(int index, TDataItem dataItem)
{
if (!HasIdValue(dataItem)) SetNewId(dataItem);
return _dataSource.Insert(index, dataItem, true);
}
public Task InsertDataItemAsync(int index, TDataItem dataItem)
{
if (!HasIdValue(dataItem)) SetNewId(dataItem);
_dataSource.Insert(index, dataItem);
return SaveChangesToServerAsync();
}
protected PropertyInfo? GetDataItemPropertyInfo(string propertyName)
=> typeof(TDataItem).GetProperty(propertyName);
protected virtual async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{
var editModel = (e.EditModel as TDataItem)!;
if (e.IsNew)
{
if (!HasIdValue(editModel)) SetNewId(editModel);
if (ParentDataItem != null && !KeyFieldNameToParentId.IsNullOrWhiteSpace())
{
KeyFieldPropertyInfoToParent ??= GetDataItemPropertyInfo(KeyFieldNameToParentId);
KeyFieldPropertyInfoToParent!.SetValue(editModel, ParentDataItem.Id);
}
e.EditModel = editModel;
}
// Set edit state
GridEditState = e.IsNew ? MgGridEditState.New : MgGridEditState.Edit;
await OnGridCustomizeEditModel.InvokeAsync(e);
// Update InfoPanel to edit mode
InfoPanelInstance?.SetEditMode(this, editModel);
await InvokeAsync(StateHasChanged);
}
private async Task OnEditStart(GridEditStartEventArgs e)
{
await OnGridEditStart.InvokeAsync(e);
}
protected virtual async Task OnFocusedRowChanged(GridFocusedRowChangedEventArgs e)
{
_focusedDataItem = e.DataItem;
var infoPanelInstance = InfoPanelInstance;
if (infoPanelInstance != null && e.DataItem != null)
{
// Ha edit módban vagyunk, de a felhasználó egy másik sorra kattintott,
// akkor kilépünk az edit módból
if (GridEditState != MgGridEditState.None)
{
infoPanelInstance.ClearEditMode();
}
// Frissítjük az InfoPanel-t az új sor adataival
infoPanelInstance.RefreshData(this, e.DataItem, e.VisibleIndex);
}
await OnGridFocusedRowChanged.InvokeAsync(e);
}
private async Task OnItemSaving(GridEditModelSavingEventArgs e)
{
var dataItem = (e.EditModel as TDataItem)!;
if (e.IsNew)
{
if (!HasIdValue(dataItem)) SetNewId(dataItem);
}
var logText = e.IsNew ? "add" : "update";
Logger.Debug($"{_gridLogName} OnItemSaving {logText}; Id: {dataItem.Id}");
await OnGridEditModelSaving.InvokeAsync(e);
if (e.Cancel)
{
Logger.Debug($"{_gridLogName} OnItemSaving {logText} canceled; Id: {dataItem.Id}");
return;
}
if (e.IsNew)
{
if (EditNewRowPosition is GridEditNewRowPosition.FixedOnTop or GridEditNewRowPosition.Top) await AddDataItemAsync(dataItem);
else await InsertDataItemAsync(0, dataItem);
}
else await UpdateDataItemAsync(dataItem);
GridEditState = MgGridEditState.None;
InfoPanelInstance?.ClearEditMode();
await InvokeAsync(StateHasChanged);
}
private async Task OnEditCanceling(GridEditCancelingEventArgs e)
{
GridEditState = MgGridEditState.None;
InfoPanelInstance?.ClearEditMode();
await InvokeAsync(StateHasChanged);
}
private Task SaveChangesToServerAsync()
{
try
{
return _dataSource.SaveChangesAsync();
}
catch (Exception ex)
{
Logger.Error($"{_gridLogName} SaveChangesToServerAsync->SaveChangesAsync error!", ex);
}
return Task.CompletedTask;
}
private async Task<bool> SaveChangesToServer()
{
var result = false;
try
{
var unsavedItems = await _dataSource.SaveChanges();
if (!(result = unsavedItems.Count == 0))
Logger.Error($"{_gridLogName} SaveChangesToServer->SaveChanges error! unsavedCount: {unsavedItems.Count}");
}
catch (Exception ex)
{
Logger.Error($"{_gridLogName} OnItemSaving", ex);
}
return result;
}
private async Task OnItemDeleting(GridDataItemDeletingEventArgs e)
{
Logger.Debug($"{_gridLogName} OnItemDeleting");
await OnGridItemDeleting.InvokeAsync(e);
if (e.Cancel)
{
Logger.Debug($"{_gridLogName} OnItemDeleting canceled");
return;
}
var dataItem = (e.DataItem as TDataItem)!;
await RemoveDataItem(dataItem);
}
private void OnCustomizeElement(GridCustomizeElementEventArgs e)
{
if (e.ElementType == GridElementType.DetailCell)
{
e.Style = "padding: 0.5rem; opacity: 0.75";
}
else if (false && e.ElementType == GridElementType.DataCell && e.Column.Name == nameof(IId<TId>.Id))
{
e.Column.Visible = AcDomain.IsDeveloperVersion;
e.Column.ShowInColumnChooser = AcDomain.IsDeveloperVersion;
}
// Apply edit mode background to the row being edited
if (e.ElementType == GridElementType.DataRow && GridEditState != MgGridEditState.None)
{
if (e.VisibleIndex == GetFocusedRowIndex())
{
e.Style = string.IsNullOrEmpty(e.Style)
? "background-color: #fffbeb;"
: e.Style + " background-color: #fffbeb;";
}
}
// Apply edit mode background to cells in the edited row
else if (e.ElementType == GridElementType.DataCell && GridEditState != MgGridEditState.None)
{
if (e.VisibleIndex == GetFocusedRowIndex())
{
e.Style = string.IsNullOrEmpty(e.Style)
? "background-color: #fffbeb;"
: e.Style + " background-color: #fffbeb;";
}
}
}
protected override async Task SetParametersAsyncCore(ParameterView parameters)
{
await base.SetParametersAsyncCore(parameters);
if (!IsFirstInitializeParameterCore)
{
//if (typeof(TDataItem) is IId<TId> || typeof(TDataItem) is IId<TId>)
KeyFieldName = "Id";
base.DataItemDeleting = EventCallback.Factory.Create<GridDataItemDeletingEventArgs>(this, OnItemDeleting);
base.EditModelSaving = EventCallback.Factory.Create<GridEditModelSavingEventArgs>(this, OnItemSaving);
base.CustomizeEditModel = EventCallback.Factory.Create<GridCustomizeEditModelEventArgs>(this, OnCustomizeEditModel);
base.FocusedRowChanged = EventCallback.Factory.Create<GridFocusedRowChangedEventArgs>(this, OnFocusedRowChanged);
base.EditStart = EventCallback.Factory.Create<GridEditStartEventArgs>(this, OnEditStart);
base.EditCanceling = EventCallback.Factory.Create<GridEditCancelingEventArgs>(this, OnEditCanceling);
CustomizeElement += OnCustomizeElement;
//ShowFilterRow = true;
//PageSize = 4;
//ShowGroupPanel = true;
//AllowSort = false;
TextWrapEnabled = false;
AllowSelectRowByClick = true;
HighlightRowOnHover = true;
AutoCollapseDetailRow = true;
AutoExpandAllGroupRows = false;
IsFirstInitializeParameterCore = true;
}
}
protected override void OnParametersSet()
{
if (!IsFirstInitializeParameters)
{
if (GridName.IsNullOrWhiteSpace()) GridName = $"{typeof(TDataItem).Name}Grid";
_gridLogName = $"[{GridName}]";
// Set default AutoSaveLayoutName if not provided
if (AutoSaveLayoutName.IsNullOrWhiteSpace())
AutoSaveLayoutName = $"Grid{typeof(TDataItem).Name}";
// Set up layout auto-loading/saving
LayoutAutoLoading = Grid_LayoutAutoLoading;
LayoutAutoSaving = Grid_LayoutAutoSaving;
// Register this grid with the wrapper for splitter size persistence
GridWrapper?.RegisterGrid(this);
IsFirstInitializeParameters = true;
}
base.OnParametersSet();
}
#region Layout Persistence
/// <summary>
/// Gets the user-specific layout storage key. Override to provide custom user identification.
/// </summary>
protected virtual int GetLayoutUserId() => 0;
/// <summary>
/// Stores the default layout (before any saved layout is loaded) for reset functionality
/// </summary>
private string? _defaultLayoutJson = null;
/// <summary>
/// Checks if a layout exists in localStorage without loading its content
/// </summary>
protected virtual async Task<string?> GetStorageItem(string localStorageKey)
{
try
{
return await JSRuntime.InvokeAsync<string>("localStorage.getItem", localStorageKey);
}
catch
{
// Mute exceptions for the server prerender stage
}
return null;
}
private async Task Grid_LayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
// Save the default layout before loading any saved layout
_defaultLayoutJson ??= JsonSerializer.Serialize(SaveLayout());
e.Layout = await LoadLayoutFromLocalStorageAsync(AutomaticLayoutStorageKey);
}
private async Task Grid_LayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
await SaveLayoutToLocalStorageAsync(e.Layout, AutomaticLayoutStorageKey);
}
protected virtual async Task<GridPersistentLayout?> LoadLayoutFromLocalStorageAsync(string localStorageKey)
{
try
{
var json = await GetStorageItem(localStorageKey);
if (!string.IsNullOrWhiteSpace(json))
return JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
catch
{
// Mute exceptions for the server prerender stage
}
return null;
}
protected virtual async Task SaveLayoutToLocalStorageAsync(GridPersistentLayout layout, string localStorageKey)
{
try
{
var json = JsonSerializer.Serialize(layout);
await JSRuntime.InvokeVoidAsync("localStorage.setItem", localStorageKey, json);
}
catch
{
// Mute exceptions for the server prerender stage
}
}
protected virtual async Task RemoveLayoutFromLocalStorageAsync(string localStorageKey)
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.removeItem", localStorageKey);
}
catch
{
// Mute exceptions for the server prerender stage
}
}
/// <inheritdoc />
public async Task SaveUserLayoutAsync()
{
var layout = SaveLayout();
await SaveLayoutToLocalStorageAsync(layout, UserLayoutStorageKey);
await SaveLayoutToLocalStorageAsync(layout, AutomaticLayoutStorageKey);
}
/// <inheritdoc />
public async Task LoadUserLayoutAsync()
{
var layout = await LoadLayoutFromLocalStorageAsync(UserLayoutStorageKey);
if (layout != null)
{
LoadLayout(layout);
}
}
/// <inheritdoc />
public async Task ResetLayoutAsync()
{
await RemoveLayoutFromLocalStorageAsync(AutomaticLayoutStorageKey);
// Restore the default layout if available
if (!string.IsNullOrWhiteSpace(_defaultLayoutJson))
{
var defaultLayout = JsonSerializer.Deserialize<GridPersistentLayout>(_defaultLayoutJson);
if (defaultLayout != null)
LoadLayout(defaultLayout);
}
}
/// <inheritdoc />
public async Task<bool> HasUserLayoutAsync()
{
return !(await GetStorageItem(UserLayoutStorageKey)).IsNullOrWhiteSpace();
}
#endregion
//public Task AddDataItem(TDataItem dataItem, int messageTag) => PostDataToServerAsync(dataItem, messageTag, TrackingState.Add);
/// <summary>
/// Force grid re-initialization
/// </summary>
/// <returns></returns>
public async Task ForceRenderAsync()
{
// Force grid re-initialization by changing the render key
_gridRenderKey = Guid.NewGuid();
await InvokeAsync(StateHasChanged);
}
public Task UpdateDataItem(TDataItem dataItem) => _dataSource.Update(dataItem, true);
public Task UpdateDataItemAsync(TDataItem dataItem)
{
_dataSource.Update(dataItem, false);
return SaveChangesToServerAsync();
}
//public Task UpdateDataItem(TDataItem dataItem, int messageTag) => PostDataToServerAsync(dataItem, messageTag, TrackingState.Update);
public Task AddOrUpdateDataItem(TDataItem dataItem) => _dataSource.AddOrUpdate(dataItem, true);
public Task RemoveDataItem(TDataItem dataItem) => _dataSource.Remove(dataItem, true);
//public Task RemoveDataItem(TDataItem dataItem, int messageTag) => PostDataToServerAsync(dataItem, messageTag, TrackingState.Remove);
public Task RemoveDataItem(TId id) => RemoveDataItem(id, RemoveMessageTag);
public Task RemoveDataItem(TId id, int messageTag)
{
return _dataSource.Remove(id, true);
}
public Task ReloadDataSourceAsync()
{
return _dataSource.LoadDataSourceAsync(false);
}
/// <summary>
/// Navigates to the previous row in the grid
/// </summary>
public void StepPrevRow()
{
var currentIndex = GetFocusedRowIndex();
if (currentIndex > 0)
{
SetFocusedRowIndex(currentIndex - 1);
}
}
/// <summary>
/// Navigates to the next row in the grid
/// </summary>
public void StepNextRow()
{
var currentIndex = GetFocusedRowIndex();
var visibleRowCount = GetVisibleRowCount();
if (currentIndex >= 0 && currentIndex < visibleRowCount - 1)
{
SetFocusedRowIndex(currentIndex + 1);
}
}
public async ValueTask DisposeAsync()
{
if (_isDisposed) return;
_isDisposed = true;
// Unsubscribe from events to prevent callbacks to disposed component
if (_dataSource != null)
{
_dataSource.OnDataSourceLoaded -= OnDataSourceLoaded;
_dataSource.OnDataSourceItemChanged -= OnDataSourceItemChanged;
_dataSource.OnSyncingStateChanged -= OnDataSourceSyncingStateChanged;
}
CustomizeElement -= OnCustomizeElement;
// Dispose base if it implements IAsyncDisposable
if (this is IAsyncDisposable asyncDisposable && asyncDisposable != this)
{
await asyncDisposable.DisposeAsync();
}
GC.SuppressFinalize(this);
}
}
public class GridDataItemChangingEventArgs<TDataItem> : GridDataItemChangedEventArgs<TDataItem> where TDataItem : class
{
internal GridDataItemChangingEventArgs(IMgGridBase grid, TDataItem dataItem, TrackingState trackingState) : base(grid, dataItem, trackingState)
{
}
public bool IsCanceled { get; set; }
}
public class GridDataItemChangedEventArgs<TDataItem> where TDataItem : class
{
internal GridDataItemChangedEventArgs(IMgGridBase grid, TDataItem dataItem, TrackingState trackingState)
{
Grid = grid ?? throw new ArgumentNullException(nameof(grid));
DataItem = dataItem;
TrackingState = trackingState;
}
public IMgGridBase Grid { get; }
public TDataItem DataItem { get; }
public TrackingState TrackingState { get; }
public bool CancelStateChangeInvoke { get; set; }
}

View File

@ -0,0 +1,160 @@
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using System.Collections.Concurrent;
using System.Linq.Expressions;
using System.Text;
using System.Text.RegularExpressions;
namespace AyCode.Blazor.Components.Components.Grids;
/// <summary>
/// Extended DxGridDataColumn with additional parameters for InfoPanel support.
/// </summary>
public partial class MgGridDataColumn : DxGridDataColumn
{
private static readonly ConcurrentDictionary<(Type Type, string Property), Func<object, object?>?> SAccessorCache = new();
private string? _urlLink;
private bool _isInitialized;
private TemplatePart[]? _templateParts;
/// <summary>
/// Whether this column should be visible in the InfoPanel. Default is true.
/// </summary>
[Parameter]
public bool ShowInInfoPanel { get; set; } = true;
/// <summary>
/// Custom display format for InfoPanel (overrides DisplayFormat if set).
/// </summary>
[Parameter]
public string? InfoPanelDisplayFormat { get; set; }
/// <summary>
/// Column order in InfoPanel (lower = earlier). Default is int.MaxValue.
/// </summary>
[Parameter]
public int InfoPanelOrder { get; set; } = int.MaxValue;
/// <summary>
/// URL template with {property} placeholders that will be replaced with row values.
/// Example: https://shop.fruitbank.hu/Admin/Order/Edit/{Id}/{OrderId}
/// </summary>
[Parameter]
public string? UrlLink
{
get => _urlLink;
set
{
if (_urlLink == value) return;
_urlLink = value;
if (_isInitialized) UpdateCellDisplayTemplate();
}
}
protected override void OnParametersSet()
{
base.OnParametersSet();
_isInitialized = true;
UpdateCellDisplayTemplate();
}
private void UpdateCellDisplayTemplate()
{
if (string.IsNullOrWhiteSpace(_urlLink)) return;
_templateParts = ParseTemplate(_urlLink);
var parts = _templateParts;
CellDisplayTemplate = context => builder =>
{
var url = BuildUrl(parts, context.DataItem);
builder.OpenElement(0, "a");
builder.AddAttribute(1, "href", url);
builder.AddAttribute(2, "target", "_blank");
builder.AddAttribute(3, "style", "text-decoration: underline; color: inherit;");
builder.AddContent(4, context.DisplayText);
builder.CloseElement();
};
}
/// <summary>
/// Represents a parsed segment of a URL template: either a literal string or a property placeholder.
/// </summary>
internal readonly record struct TemplatePart(string Value, bool IsProperty);
[GeneratedRegex(@"\{([^}]+)\}")]
private static partial Regex TemplateRegex();
/// <summary>
/// Parses a URL template into literal and property placeholder segments.
/// </summary>
internal static TemplatePart[] ParseTemplate(string template)
{
var parts = new List<TemplatePart>();
var lastIndex = 0;
foreach (Match match in TemplateRegex().Matches(template))
{
if (match.Index > lastIndex) parts.Add(new TemplatePart(template[lastIndex..match.Index], IsProperty: false));
parts.Add(new TemplatePart(match.Groups[1].Value, IsProperty: true));
lastIndex = match.Index + match.Length;
}
if (lastIndex < template.Length) parts.Add(new TemplatePart(template[lastIndex..], IsProperty: false));
return [.. parts];
}
/// <summary>
/// Builds a URL from pre-parsed template parts using cached compiled property accessors.
/// </summary>
internal static string BuildUrl(TemplatePart[] parts, object? dataItem)
{
if (dataItem is null || parts.Length == 0)
return string.Empty;
var type = dataItem.GetType();
var sb = new StringBuilder(parts.Length * 16);
foreach (var part in parts)
{
if (!part.IsProperty)
{
sb.Append(part.Value);
continue;
}
var accessor = SAccessorCache.GetOrAdd((type, part.Value), static key => CompileAccessor(key.Type, key.Property));
if (accessor is not null) sb.Append(accessor(dataItem)?.ToString() ?? string.Empty);
else sb.Append('{').Append(part.Value).Append('}');
}
return sb.ToString();
}
/// <summary>
/// Replaces {property} placeholders in the template with values from the data item.
/// Convenience overload that parses the template on each call — prefer pre-parsed <see cref="BuildUrl"/> for hot paths.
/// </summary>
internal static string BuildUrlFromTemplate(string template, object? dataItem)
{
return dataItem is null ? template : BuildUrl(ParseTemplate(template), dataItem);
}
private static Func<object, object?>? CompileAccessor(Type type, string propertyName)
{
var prop = type.GetProperty(propertyName);
if (prop is null) return null;
var param = Expression.Parameter(typeof(object), "obj");
var body = Expression.Convert(
Expression.Property(Expression.Convert(param, type), prop),
typeof(object));
return Expression.Lambda<Func<object, object?>>(body, param).Compile();
}
}

View File

@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace AyCode.Blazor.Components.Components.Grids
{
internal class MgGridHelper
{
}
}

View File

@ -0,0 +1,401 @@
@using DevExpress.Blazor
@using Microsoft.AspNetCore.Components.Rendering
@using System.Reflection
<div @ref="_panelElement" class="mg-grid-info-panel @(_isEditMode ? "edit-mode" : "") @GetColumnCountClass()">
@* Header *@
@if (HeaderTemplate != null)
{
@HeaderTemplate(CreateContext())
}
else if (_currentGrid != null)
{
<div class="mg-info-panel-header">@_currentGrid.Caption</div>
}
@* Toolbar *@
@if (_currentGrid != null)
{
<div class="mg-info-panel-toolbar">
<MgGridToolbarTemplate Grid="_currentGrid" OnlyGridEditTools="true" ShowOnlyIcon="true" />
</div>
}
@* Content *@
<div class="mg-info-panel-content">
@if (GetActiveDataItem() != null && _currentGrid != null)
{
@if (BeforeColumnsTemplate != null)
{
@BeforeColumnsTemplate(CreateContext())
}
@if (ColumnsTemplate != null)
{
@ColumnsTemplate(CreateContext())
}
else
{
@RenderDefaultColumns()
}
@if (AfterColumnsTemplate != null)
{
@AfterColumnsTemplate(CreateContext())
}
}
else
{
<div class="mg-info-panel-empty">
<p>Válasszon ki egy sort az adatok megtekintéséhez</p>
</div>
}
</div>
@* Footer *@
@if (FooterTemplate != null)
{
@FooterTemplate(CreateContext())
}
</div>
@code {
[Parameter] public RenderFragment<InfoPanelContext>? HeaderTemplate { get; set; }
[Parameter] public RenderFragment<InfoPanelContext>? BeforeColumnsTemplate { get; set; }
[Parameter] public RenderFragment<InfoPanelContext>? ColumnsTemplate { get; set; }
[Parameter] public RenderFragment<InfoPanelContext>? AfterColumnsTemplate { get; set; }
[Parameter] public RenderFragment<InfoPanelContext>? FooterTemplate { get; set; }
/// <summary>
/// Called when the data item changes (row selection changed)
/// </summary>
[Parameter] public EventCallback<object?> OnDataItemChanged { get; set; }
private InfoPanelContext CreateContext() => new(GetActiveDataItem(), _isEditMode);
private string GetColumnCountClass() => FixedColumnCount switch
{
1 => "mg-columns-1",
2 => "mg-columns-2",
3 => "mg-columns-3",
4 => "mg-columns-4",
_ => ""
};
private RenderFragment RenderDefaultColumns() => builder =>
{
var dataItem = GetActiveDataItem();
if (dataItem == null) return;
var dataItemType = dataItem.GetType();
var seq = 0;
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "mg-info-panel-grid");
foreach (var column in GetVisibleColumns())
{
var displayText = GetDisplayTextFromGrid(column);
var value = GetCellValue(column);
var settingsType = GetEditSettingsType(column);
var isEditable = _isEditMode && !column.ReadOnly;
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "mg-info-panel-item");
builder.OpenElement(seq++, "label");
builder.AddAttribute(seq++, "class", isEditable ? "mg-info-panel-label editable" : "mg-info-panel-label");
builder.AddContent(seq++, GetColumnCaption(column));
builder.CloseElement();
builder.OpenElement(seq++, "div");
if (isEditable)
{
RenderEditableCell(column, dataItem, dataItemType, value, displayText, settingsType)(builder);
}
else
{
RenderCellContent(value, displayText, column, dataItem)(builder);
}
builder.CloseElement();
builder.CloseElement();
}
builder.CloseElement();
};
private static string GetColumnCaption(DxGridDataColumn column) =>
!string.IsNullOrWhiteSpace(column.Caption) ? column.Caption : column.FieldName;
private RenderFragment RenderEditableCell(DxGridDataColumn column, object dataItem, Type dataItemType, object? value, string displayText, EditSettingsType settingsType)
{
return builder =>
{
var seq = 0;
var propertyInfo = dataItemType.GetProperty(column.FieldName);
if (propertyInfo == null)
{
RenderCellContent(value, displayText)(builder);
return;
}
var underlyingType = Nullable.GetUnderlyingType(propertyInfo.PropertyType) ?? propertyInfo.PropertyType;
if (settingsType == EditSettingsType.ComboBox && GetEditSettingsCached(dataItemType, column.FieldName) is DxComboBoxSettings comboSettings)
{
RenderComboBoxEditor(builder, ref seq, dataItem, propertyInfo, comboSettings);
return;
}
if (underlyingType == typeof(bool)) RenderCheckBoxEditor(builder, ref seq, dataItem, propertyInfo);
else if (underlyingType == typeof(DateTime)) RenderDateTimeEditor(builder, ref seq, dataItem, propertyInfo, column.DisplayFormat);
else if (underlyingType == typeof(DateOnly)) RenderDateOnlyEditor(builder, ref seq, dataItem, propertyInfo, column.DisplayFormat);
else if (underlyingType == typeof(int)) RenderSpinIntEditor(builder, ref seq, dataItem, propertyInfo);
else if (underlyingType == typeof(decimal)) RenderSpinDecimalEditor(builder, ref seq, dataItem, propertyInfo);
else if (underlyingType == typeof(double)) RenderSpinDoubleEditor(builder, ref seq, dataItem, propertyInfo);
else if (settingsType == EditSettingsType.Memo) RenderMemoEditor(builder, ref seq, dataItem, propertyInfo);
else RenderTextBoxEditor(builder, ref seq, dataItem, propertyInfo);
};
}
private void RenderCheckBoxEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
{
builder.OpenComponent<DxCheckBox<bool>>(seq++);
builder.AddAttribute(seq++, "Checked", (bool)(propertyInfo.GetValue(dataItem) ?? false));
builder.AddAttribute(seq++, "CheckedChanged", EventCallback.Factory.Create<bool>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
builder.CloseComponent();
}
private void RenderDateTimeEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, string? displayFormat)
{
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
var value = propertyInfo.GetValue(dataItem);
if (isNullable)
{
builder.OpenComponent<DxDateEdit<DateTime?>>(seq++);
builder.AddAttribute(seq++, "Date", (DateTime?)value);
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateTime?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
else
{
builder.OpenComponent<DxDateEdit<DateTime>>(seq++);
builder.AddAttribute(seq++, "Date", (DateTime)(value ?? DateTime.MinValue));
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateTime>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
builder.AddAttribute(seq++, "DisplayFormat", displayFormat ?? "yyyy-MM-dd HH:mm");
builder.CloseComponent();
}
private void RenderDateOnlyEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, string? displayFormat)
{
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
var value = propertyInfo.GetValue(dataItem);
if (isNullable)
{
builder.OpenComponent<DxDateEdit<DateOnly?>>(seq++);
builder.AddAttribute(seq++, "Date", (DateOnly?)value);
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateOnly?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
else
{
builder.OpenComponent<DxDateEdit<DateOnly>>(seq++);
builder.AddAttribute(seq++, "Date", (DateOnly)(value ?? DateOnly.MinValue));
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateOnly>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
builder.AddAttribute(seq++, "DisplayFormat", displayFormat ?? "yyyy-MM-dd");
builder.CloseComponent();
}
private void RenderSpinIntEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
{
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
var value = propertyInfo.GetValue(dataItem);
if (isNullable)
{
builder.OpenComponent<DxSpinEdit<int?>>(seq++);
builder.AddAttribute(seq++, "Value", (int?)value);
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<int?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
else
{
builder.OpenComponent<DxSpinEdit<int>>(seq++);
builder.AddAttribute(seq++, "Value", (int)(value ?? 0));
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<int>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
builder.CloseComponent();
}
private void RenderSpinDecimalEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
{
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
var value = propertyInfo.GetValue(dataItem);
if (isNullable)
{
builder.OpenComponent<DxSpinEdit<decimal?>>(seq++);
builder.AddAttribute(seq++, "Value", (decimal?)value);
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<decimal?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
else
{
builder.OpenComponent<DxSpinEdit<decimal>>(seq++);
builder.AddAttribute(seq++, "Value", (decimal)(value ?? 0m));
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<decimal>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
builder.CloseComponent();
}
private void RenderSpinDoubleEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
{
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
var value = propertyInfo.GetValue(dataItem);
if (isNullable)
{
builder.OpenComponent<DxSpinEdit<double?>>(seq++);
builder.AddAttribute(seq++, "Value", (double?)value);
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<double?>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
else
{
builder.OpenComponent<DxSpinEdit<double>>(seq++);
builder.AddAttribute(seq++, "Value", (double)(value ?? 0d));
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<double>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
}
builder.CloseComponent();
}
private void RenderTextBoxEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
{
builder.OpenComponent<DxTextBox>(seq++);
builder.AddAttribute(seq++, "Text", propertyInfo.GetValue(dataItem)?.ToString() ?? "");
builder.AddAttribute(seq++, "TextChanged", EventCallback.Factory.Create<string>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
builder.CloseComponent();
}
private void RenderMemoEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo)
{
builder.OpenComponent<DxMemo>(seq++);
builder.AddAttribute(seq++, "Text", propertyInfo.GetValue(dataItem)?.ToString() ?? "");
builder.AddAttribute(seq++, "TextChanged", EventCallback.Factory.Create<string>(this, v => { propertyInfo.SetValue(dataItem, v); InvokeAsync(StateHasChanged); }));
builder.AddAttribute(seq++, "Rows", 3);
builder.CloseComponent();
}
private void RenderComboBoxEditor(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, DxComboBoxSettings settings)
{
var value = propertyInfo.GetValue(dataItem);
var underlyingType = Nullable.GetUnderlyingType(propertyInfo.PropertyType) ?? propertyInfo.PropertyType;
var itemType = settings.Data?.GetType().GetGenericArguments().FirstOrDefault() ?? typeof(object);
if (underlyingType == typeof(int))
RenderComboBoxInt(builder, ref seq, dataItem, propertyInfo, settings, itemType, value);
else if (underlyingType == typeof(long))
RenderComboBoxLong(builder, ref seq, dataItem, propertyInfo, settings, itemType, value);
else if (underlyingType == typeof(Guid))
RenderComboBoxGuid(builder, ref seq, dataItem, propertyInfo, settings, itemType, value);
else
{
builder.OpenComponent<DxTextBox>(seq++);
builder.AddAttribute(seq++, "Text", ResolveComboBoxDisplayText(settings, value ?? new object()) ?? value?.ToString() ?? "");
builder.AddAttribute(seq++, "ReadOnly", true);
builder.CloseComponent();
}
}
private void RenderComboBoxInt(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, DxComboBoxSettings settings, Type itemType, object? currentValue)
{
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
var comboType = isNullable ? typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(int?)) : typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(int));
builder.OpenComponent(seq++, comboType);
builder.AddAttribute(seq++, "Data", settings.Data);
builder.AddAttribute(seq++, "ValueFieldName", settings.ValueFieldName);
builder.AddAttribute(seq++, "TextFieldName", settings.TextFieldName);
builder.AddAttribute(seq++, "Value", isNullable ? currentValue as int? : (currentValue is int intVal ? intVal : 0));
builder.AddAttribute(seq++, "ValueChanged", isNullable
? EventCallback.Factory.Create<int?>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); })
: EventCallback.Factory.Create<int>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); }));
builder.AddAttribute(seq++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
builder.CloseComponent();
}
private void RenderComboBoxLong(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, DxComboBoxSettings settings, Type itemType, object? currentValue)
{
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
var comboType = isNullable ? typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(long?)) : typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(long));
builder.OpenComponent(seq++, comboType);
builder.AddAttribute(seq++, "Data", settings.Data);
builder.AddAttribute(seq++, "ValueFieldName", settings.ValueFieldName);
builder.AddAttribute(seq++, "TextFieldName", settings.TextFieldName);
builder.AddAttribute(seq++, "Value", isNullable ? currentValue as long? : (currentValue is long longVal ? longVal : 0L));
builder.AddAttribute(seq++, "ValueChanged", isNullable
? EventCallback.Factory.Create<long?>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); })
: EventCallback.Factory.Create<long>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); }));
builder.AddAttribute(seq++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
builder.CloseComponent();
}
private void RenderComboBoxGuid(RenderTreeBuilder builder, ref int seq, object dataItem, PropertyInfo propertyInfo, DxComboBoxSettings settings, Type itemType, object? currentValue)
{
var isNullable = Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null;
var comboType = isNullable ? typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(Guid?)) : typeof(DxComboBox<,>).MakeGenericType(itemType, typeof(Guid));
builder.OpenComponent(seq++, comboType);
builder.AddAttribute(seq++, "Data", settings.Data);
builder.AddAttribute(seq++, "ValueFieldName", settings.ValueFieldName);
builder.AddAttribute(seq++, "TextFieldName", settings.TextFieldName);
builder.AddAttribute(seq++, "Value", isNullable ? currentValue as Guid? : (currentValue is Guid guidVal ? guidVal : Guid.Empty));
builder.AddAttribute(seq++, "ValueChanged", isNullable
? EventCallback.Factory.Create<Guid?>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); })
: EventCallback.Factory.Create<Guid>(this, v => { propertyInfo.SetValue(dataItem, v); StateHasChanged(); }));
builder.AddAttribute(seq++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
builder.CloseComponent();
}
private RenderFragment RenderCellContent(object? value, string displayText, DxGridDataColumn? column = null, object? dataItem = null)
{
return builder =>
{
var seq = 0;
// Check if column has UrlLink
if (column is MgGridDataColumn mgColumn && !string.IsNullOrWhiteSpace(mgColumn.UrlLink) && dataItem != null)
{
var url = MgGridDataColumn.BuildUrlFromTemplate(mgColumn.UrlLink, dataItem);
builder.OpenElement(seq++, "a");
builder.AddAttribute(seq++, "href", url);
builder.AddAttribute(seq++, "target", "_blank");
builder.AddAttribute(seq++, "class", "mg-info-panel-link");
builder.AddAttribute(seq++, "title", displayText);
builder.AddContent(seq++, displayText);
builder.CloseElement();
return;
}
builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", "mg-info-panel-value");
builder.AddAttribute(seq++, "title", displayText);
if (value is bool boolValue)
{
builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", boolValue ? "dx-icon dx-icon-check" : "dx-icon dx-icon-close");
builder.CloseElement();
}
else
{
builder.AddContent(seq++, displayText);
}
builder.CloseElement();
};
}
}

View File

@ -0,0 +1,488 @@
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace AyCode.Blazor.Components.Components.Grids;
/// <summary>
/// Interface for InfoPanel to support grid access
/// </summary>
public interface IInfoPanelBase
{
void ClearEditMode();
void SetEditMode(IMgGridBase grid, object editModel);
void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1);
}
/// <summary>
/// Context for InfoPanel templates containing data item and edit mode state
/// </summary>
public record InfoPanelContext(object? DataItem, bool IsEditMode);
/// <summary>
/// InfoPanel component for displaying and editing grid row details
/// </summary>
public partial class MgGridInfoPanel : ComponentBase, IAsyncDisposable, IInfoPanelBase
{
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
/// <summary>
/// Whether to show readonly fields when in edit mode. Default is false.
/// </summary>
[Parameter] public bool ShowReadOnlyFieldsInEditMode { get; set; } = false;
/// <summary>
/// Minimum width for 2 columns layout. Default is 500px.
/// </summary>
[Parameter] public int TwoColumnBreakpoint { get; set; } = 400;
/// <summary>
/// Minimum width for 3 columns layout. Default is 800px.
/// </summary>
[Parameter] public int ThreeColumnBreakpoint { get; set; } = 800;
/// <summary>
/// Minimum width for 4 columns layout. Default is 1200px.
/// </summary>
[Parameter] public int FourColumnBreakpoint { get; set; } = 1300;
/// <summary>
/// Fixed column count. If set (1-4), overrides responsive breakpoints. Default is null (responsive).
/// </summary>
[Parameter] public int? FixedColumnCount { get; set; }
/// <summary>
/// Reference to the wrapper component - automatically registers this InfoPanel
/// </summary>
[CascadingParameter]
public MgGridWithInfoPanel? GridWrapper { get; set; }
private ElementReference _panelElement;
private bool _isJsInitialized;
private const int DefaultTopOffset = 300;
protected IMgGridBase? _currentGrid;
protected object? _currentDataItem;
protected int _focusedRowVisibleIndex = -1;
protected List<DxGridDataColumn> _allDataColumns = [];
// Edit mode state
protected bool _isEditMode;
protected object? _editModel;
// Type-based caches for performance optimization
private readonly Dictionary<(Type, string), IEditSettings?> _editSettingsCache = [];
private readonly Dictionary<Type, List<DxGridDataColumn>> _columnsCache = [];
private readonly Dictionary<(Type, object, object), string?> _comboBoxTextCache = [];
// Track if we need to update UI
private bool _pendingStateChange;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
GridWrapper?.RegisterInfoPanel(this);
await InitializeStickyAsync();
}
}
private async Task InitializeStickyAsync()
{
try
{
await JSRuntime.InvokeVoidAsync(
"MgGridInfoPanel.initSticky",
_panelElement,
DefaultTopOffset);
_isJsInitialized = true;
}
catch (JSException)
{
// JS might not be loaded yet, ignore
}
}
/// <summary>
/// Refreshes the InfoPanel with data from the specified grid row (view mode)
/// </summary>
public void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1)
{
ArgumentNullException.ThrowIfNull(grid);
_currentGrid = grid;
_currentDataItem = dataItem;
_focusedRowVisibleIndex = visibleIndex;
// Clear edit mode when refreshing with new data
_isEditMode = false;
_editModel = null;
// Use cached columns if available
if (_currentGrid != null && _currentDataItem != null)
{
var dataItemType = _currentDataItem.GetType();
_allDataColumns = GetAllDataColumnsCached(dataItemType, _currentGrid);
}
else
{
_allDataColumns = [];
}
// Batch state changes
if (!_pendingStateChange)
{
_pendingStateChange = true;
InvokeAsync(async () =>
{
_pendingStateChange = false;
StateHasChanged();
await OnDataItemChanged.InvokeAsync(dataItem);
});
}
}
/// <summary>
/// Sets the InfoPanel to edit mode with the given edit model
/// </summary>
public void SetEditMode(IMgGridBase grid, object editModel)
{
ArgumentNullException.ThrowIfNull(grid);
ArgumentNullException.ThrowIfNull(editModel);
_currentGrid = grid;
_editModel = editModel;
_isEditMode = true;
_currentDataItem = _editModel;
var dataItemType = _editModel.GetType();
_allDataColumns = GetAllDataColumnsCached(dataItemType, _currentGrid);
// Batch state changes
if (!_pendingStateChange)
{
_pendingStateChange = true;
InvokeAsync(() =>
{
_pendingStateChange = false;
StateHasChanged();
});
}
}
/// <summary>
/// Clears edit mode and returns to view mode
/// </summary>
public void ClearEditMode()
{
_isEditMode = false;
_editModel = null;
if (!_pendingStateChange)
{
_pendingStateChange = true;
InvokeAsync(() =>
{
_pendingStateChange = false;
StateHasChanged();
});
}
}
/// <summary>
/// Clears the InfoPanel completely
/// </summary>
public void Clear()
{
_currentGrid = null;
_currentDataItem = null;
_focusedRowVisibleIndex = -1;
_allDataColumns = [];
_isEditMode = false;
_editModel = null;
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
if (_isJsInitialized)
{
try
{
await JSRuntime.InvokeVoidAsync("MgGridInfoPanel.disposeSticky", _panelElement);
}
catch
{
// Ignore disposal errors
}
}
// Clear all caches on dispose
_editSettingsCache.Clear();
_columnsCache.Clear();
_comboBoxTextCache.Clear();
}
/// <summary>
/// Gets the data item to display/edit (EditModel in edit mode, otherwise CurrentDataItem)
/// </summary>
protected object? GetActiveDataItem() => _isEditMode && _editModel != null ? _editModel : _currentDataItem;
/// <summary>
/// Gets the display text for a field using the grid's internal formatting.
/// For ComboBox columns, tries to get the text from the lookup data source.
/// </summary>
protected string GetDisplayTextFromGrid(DxGridDataColumn column)
{
var dataItem = GetActiveDataItem();
if (_currentGrid == null || dataItem == null || string.IsNullOrWhiteSpace(column.FieldName))
return string.Empty;
try
{
var value = _currentGrid.GetDataItemValue(dataItem, column.FieldName);
if (value == null)
return string.Empty;
var dataItemType = dataItem.GetType();
// Try to resolve display text from EditSettings
var editSettings = GetEditSettingsCached(dataItemType, column.FieldName);
if (editSettings is DxComboBoxSettings comboSettings)
{
var displayText = ResolveComboBoxDisplayTextCached(dataItemType, comboSettings, value);
if (!string.IsNullOrEmpty(displayText))
return displayText;
}
// Apply column's DisplayFormat if available
if (!string.IsNullOrEmpty(column.DisplayFormat))
{
try
{
return string.Format(column.DisplayFormat, value);
}
catch
{
// If format fails, fall through to default formatting
}
}
return FormatValue(value);
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Gets edit settings for the specified field with Type-based caching
/// </summary>
private IEditSettings? GetEditSettingsCached(Type dataItemType, string fieldName)
{
if (_currentGrid == null || string.IsNullOrEmpty(fieldName))
return null;
var cacheKey = (dataItemType, fieldName);
if (_editSettingsCache.TryGetValue(cacheKey, out var cached))
return cached;
IEditSettings? settings = null;
try
{
// Try each EditSettings type
settings = _currentGrid.GetColumnEditSettings<DxComboBoxSettings>(fieldName)
?? _currentGrid.GetColumnEditSettings<DxDateEditSettings>(fieldName)
?? _currentGrid.GetColumnEditSettings<DxTimeEditSettings>(fieldName)
?? _currentGrid.GetColumnEditSettings<DxSpinEditSettings>(fieldName)
?? _currentGrid.GetColumnEditSettings<DxCheckBoxSettings>(fieldName)
?? _currentGrid.GetColumnEditSettings<DxMemoSettings>(fieldName)
?? (IEditSettings?)_currentGrid.GetColumnEditSettings<DxTextBoxSettings>(fieldName);
}
catch
{
// Ignore errors
}
_editSettingsCache[cacheKey] = settings;
return settings;
}
/// <summary>
/// Cached version of ResolveComboBoxDisplayText with Type-based key
/// </summary>
private string? ResolveComboBoxDisplayTextCached(Type dataItemType, DxComboBoxSettings settings, object value)
{
// Use settings object reference and value as cache key
var cacheKey = (dataItemType, (object)settings, value);
if (_comboBoxTextCache.TryGetValue(cacheKey, out var cached))
return cached;
var result = ResolveComboBoxDisplayText(settings, value);
_comboBoxTextCache[cacheKey] = result;
return result;
}
private string? ResolveComboBoxDisplayText(DxComboBoxSettings settings, object value)
{
if (settings.Data == null || string.IsNullOrEmpty(settings.ValueFieldName) || string.IsNullOrEmpty(settings.TextFieldName))
return null;
try
{
foreach (var item in (System.Collections.IEnumerable)settings.Data)
{
if (item == null) continue;
var itemType = item.GetType();
var valueProperty = itemType.GetProperty(settings.ValueFieldName);
var textProperty = itemType.GetProperty(settings.TextFieldName);
if (valueProperty == null || textProperty == null) continue;
var itemValue = valueProperty.GetValue(item);
if (itemValue != null && itemValue.Equals(value))
{
return textProperty.GetValue(item)?.ToString();
}
}
}
catch
{
// If lookup fails, return null and fall back to default formatting
}
return null;
}
private static string FormatValue(object? value)
{
if (value == null)
return string.Empty;
return value switch
{
DateTime dateTime => dateTime.ToString("yyyy-MM-dd HH:mm:ss"),
DateOnly dateOnly => dateOnly.ToString("yyyy-MM-dd"),
TimeOnly timeOnly => timeOnly.ToString("HH:mm:ss"),
TimeSpan timeSpan => timeSpan.ToString(@"hh\:mm\:ss"),
bool boolValue => boolValue ? "Igen" : "Nem",
decimal decValue => decValue.ToString("N2"),
double dblValue => dblValue.ToString("N2"),
float fltValue => fltValue.ToString("N2"),
int or long or short or byte => $"{value:N0}",
_ => value.ToString() ?? string.Empty
};
}
/// <summary>
/// Gets the columns to display based on edit mode and ShowReadOnlyFieldsInEditMode setting
/// </summary>
protected IEnumerable<DxGridDataColumn> GetVisibleColumns()
{
if (!_isEditMode || ShowReadOnlyFieldsInEditMode)
{
return _allDataColumns;
}
// In edit mode with ShowReadOnlyFieldsInEditMode=false, hide readonly columns
return _allDataColumns.Where(c => !c.ReadOnly);
}
protected object? GetCellValue(DxGridDataColumn column)
{
var dataItem = GetActiveDataItem();
if (_currentGrid == null || dataItem == null || string.IsNullOrWhiteSpace(column.FieldName))
return null;
try
{
return _currentGrid.GetDataItemValue(dataItem, column.FieldName);
}
catch
{
return null;
}
}
/// <summary>
/// Cached version of GetAllDataColumns with Type-based key
/// </summary>
private List<DxGridDataColumn> GetAllDataColumnsCached(Type dataItemType, IMgGridBase grid)
{
if (_columnsCache.TryGetValue(dataItemType, out var cached))
return cached;
var columns = GetAllDataColumns(grid);
_columnsCache[dataItemType] = columns;
return columns;
}
protected static List<DxGridDataColumn> GetAllDataColumns(IMgGridBase grid)
{
var columns = new List<DxGridDataColumn>();
try
{
var allColumns = grid.GetDataColumns();
if (allColumns != null)
{
foreach (var column in allColumns)
{
if (column is DxGridDataColumn dataColumn &&
!string.IsNullOrWhiteSpace(dataColumn.FieldName))
{
columns.Add(dataColumn);
}
}
}
}
catch (Exception)
{
// Ignore errors
}
return columns;
}
/// <summary>
/// Gets the EditSettings type for rendering logic
/// </summary>
private EditSettingsType GetEditSettingsType(DxGridDataColumn column)
{
var dataItem = GetActiveDataItem();
if (dataItem == null) return EditSettingsType.None;
var dataItemType = dataItem.GetType();
var settings = GetEditSettingsCached(dataItemType, column.FieldName);
return settings switch
{
DxComboBoxSettings => EditSettingsType.ComboBox,
DxDateEditSettings => EditSettingsType.DateEdit,
DxTimeEditSettings => EditSettingsType.TimeEdit,
DxSpinEditSettings => EditSettingsType.SpinEdit,
DxCheckBoxSettings => EditSettingsType.CheckBox,
DxMemoSettings => EditSettingsType.Memo,
_ => EditSettingsType.None
};
}
private enum EditSettingsType
{
None,
ComboBox,
DateEdit,
TimeEdit,
SpinEdit,
CheckBox,
Memo
}
}

View File

@ -0,0 +1,47 @@
/* MgGridInfoPanel scoped styles - component-specific overrides only */
/* Base styles are in wwwroot/css/mg-grid-info-panel.css */
/* Shared edit mode background color configuration */
/* Grid row background: #fffbeb (see MgGridBase.cs OnCustomizeElement) */
/* InfoPanel background: #fffbeb (see below .edit-mode) */
/* Border color: #f59e0b */
/* Edit/View mode transitions and specific colors */
.mg-grid-info-panel {
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.mg-grid-info-panel.view-mode {
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa) !important;
border-left: 3px solid transparent !important;
}
/* Fallback styles for info-panel-form (not in global CSS) */
.info-panel-form {
width: 100%;
}
.info-panel-form .fw-semibold {
font-weight: 600;
color: var(--DS-color-content-neutral-subdued-rest, #495057);
font-size: 0.875rem;
}
.info-panel-form .fw-semibold.text-primary {
color: var(--DS-color-content-primary-default-rest, #0d6efd);
}
/* Text overflow handling - show ellipsis and full text in tooltip */
.info-panel-text-wrapper {
width: 100%;
}
.info-panel-text-wrapper input[readonly] {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mg-info-panel-value-bool {
/* Keep left aligned */
}

View File

@ -0,0 +1,6 @@
namespace AyCode.Blazor.Components.Components.Grids;
public class MgGridInfoPanelHelper
{
}

View File

@ -0,0 +1,462 @@
using AyCode.Core.Helpers;
using AyCode.Core.Interfaces;
using AyCode.Core.Loggers;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
using DevExpress.Blazor;
using DevExpress.Data.Filtering;
using DevExpress.Data.Linq;
using DevExpress.Data.Linq.Helpers;
using System.Collections;
namespace AyCode.Blazor.Components.Components.Grids;
#region Models
/// <summary>
/// Sorting information for a single field
/// </summary>
public class SignalRGridSortInfo
{
public string FieldName { get; set; } = "";
public bool Descending { get; set; }
}
#endregion
/// <summary>
/// GridCustomDataSource implementation that wraps AcSignalRDataSource.
/// Provides instant local filtering for previously seen filter criteria,
/// while refreshing data in background using SignalR callback pattern.
///
/// Key features:
/// - Uses AcSignalRDataSource for caching and background populate
/// - Tracks seen filter criteria - if already seen, returns local data instantly
/// - Background refresh with callback/populate pattern (no UI blocking)
/// - Full GridCustomDataSource support (filter, sort, page, group, summary)
/// </summary>
/// <typeparam name="TDataItem">Entity type implementing IId</typeparam>
/// <typeparam name="TId">ID type (int, Guid, long, etc.)</typeparam>
public class MgGridSignalRDataSource<TDataItem, TId> : GridCustomDataSource
where TDataItem : class, IId<TId>
where TId : struct
{
private readonly AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> _innerDataSource;
private readonly AcLoggerBase? _logger;
// DevExpress CriteriaOperator to Expression converter
private readonly CriteriaToExpressionConverter _criteriaConverter = new();
// Track filter criteria that have been seen before
private readonly HashSet<string> _knownFilterCriteria = new(StringComparer.Ordinal);
// Lock for thread-safe operations
private readonly object _syncLock = new();
// Event fired when background refresh completes
public event Action? OnBackgroundRefreshCompleted;
/// <summary>
/// Creates a new MgGridSignalRDataSource wrapping an existing AcSignalRDataSource
/// </summary>
/// <param name="innerDataSource">The underlying AcSignalRDataSource that handles caching and SignalR communication</param>
/// <param name="logger">Optional logger for debugging</param>
public MgGridSignalRDataSource(
AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> innerDataSource,
AcLoggerBase? logger = null)
{
_innerDataSource = innerDataSource ?? throw new ArgumentNullException(nameof(innerDataSource));
_logger = logger;
// Subscribe to data source events
_innerDataSource.OnDataSourceLoaded += OnInnerDataSourceLoaded;
}
/// <summary>
/// Specifies the data item type for the grid
/// </summary>
protected override Type DataItemType => typeof(TDataItem);
/// <summary>
/// Gets the inner AcSignalRDataSource for direct access if needed
/// </summary>
public AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> InnerDataSource => _innerDataSource;
#region GridCustomDataSource Implementation
/// <summary>
/// Gets the total count of items matching the current filter.
/// If filter was seen before, returns local count instantly and refreshes in background.
/// </summary>
public override async Task<int> GetItemCountAsync(
GridCustomDataSourceCountOptions options,
CancellationToken cancellationToken)
{
var filterKey = GetFilterKey(options.FilterCriteria);
_logger?.Debug($"[MgGridSignalRDataSource] GetItemCountAsync - Filter: {filterKey}");
// If we have local data and this filter was seen before, return local count
if (_innerDataSource.Count > 0 && IsKnownFilter(filterKey))
{
var localCount = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria).Count;
_logger?.Debug($"[MgGridSignalRDataSource] Returning local count: {localCount}, refreshing in background");
// Refresh in background (fire-and-forget)
_ = RefreshInBackgroundAsync(filterKey);
return localCount;
}
// First time seeing this filter - must wait for server
_logger?.Debug("[MgGridSignalRDataSource] New filter, waiting for server data");
await LoadFromServerAsync();
MarkFilterAsKnown(filterKey);
return ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria).Count;
}
/// <summary>
/// Gets items for the current page with filtering and sorting applied.
/// If filter was seen before, returns local data instantly and refreshes in background.
/// </summary>
public override async Task<IList> GetItemsAsync(
GridCustomDataSourceItemsOptions options,
CancellationToken cancellationToken)
{
var filterKey = GetFilterKey(options.FilterCriteria);
_logger?.Debug($"[MgGridSignalRDataSource] GetItemsAsync - Skip: {options.StartIndex}, Take: {options.Count}, Filter: {filterKey}");
// If we have local data and this filter was seen before, return local data
if (_innerDataSource.Count > 0 && IsKnownFilter(filterKey))
{
var localResult = GetLocalItems(options);
_logger?.Debug($"[MgGridSignalRDataSource] Returning {localResult.Count} local items, refreshing in background");
// Refresh in background (fire-and-forget)
RefreshInBackgroundAsync(filterKey).Forget();
return localResult;
}
// First time seeing this filter - must wait for server
_logger?.Debug("[MgGridSignalRDataSource] New filter, waiting for server data");
await LoadFromServerAsync();
MarkFilterAsKnown(filterKey);
return GetLocalItems(options);
}
/// <summary>
/// Gets unique values for a column (used in filter dropdowns).
/// Always returns from local data.
/// </summary>
public override Task<object[]> GetUniqueValuesAsync(
GridCustomDataSourceUniqueValuesOptions options,
CancellationToken cancellationToken)
{
_logger?.Debug($"[MgGridSignalRDataSource] GetUniqueValuesAsync - Field: {options.FieldName}");
if (_innerDataSource.Count == 0)
return Task.FromResult(Array.Empty<object>());
try
{
var propertyInfo = typeof(TDataItem).GetProperty(options.FieldName);
if (propertyInfo == null)
return Task.FromResult(Array.Empty<object>());
var uniqueValues = _innerDataSource
.Select(item => propertyInfo.GetValue(item))
.Where(v => v != null)
.Distinct()
.Cast<object>()
.ToArray();
_logger?.Debug($"[MgGridSignalRDataSource] Found {uniqueValues.Length} unique values for {options.FieldName}");
return Task.FromResult(uniqueValues);
}
catch (Exception ex)
{
_logger?.Error($"[MgGridSignalRDataSource] GetUniqueValuesAsync failed: {ex.Message}", ex);
return Task.FromResult(Array.Empty<object>());
}
}
/// <summary>
/// Gets group information for grouped data.
/// Currently delegates to base implementation.
/// </summary>
public override async Task<IList<GridCustomDataSourceGroupInfo>> GetGroupInfoAsync(
GridCustomDataSourceGroupingOptions options,
CancellationToken cancellationToken)
{
_logger?.Debug("[MgGridSignalRDataSource] GetGroupInfoAsync");
// TODO: Implement local grouping when needed
return await base.GetGroupInfoAsync(options, cancellationToken);
}
/// <summary>
/// Gets total summary values.
/// Calculates from local data.
/// </summary>
public override Task<IList> GetTotalSummaryAsync(
GridCustomDataSourceTotalSummaryOptions options,
CancellationToken cancellationToken)
{
_logger?.Debug($"[MgGridSignalRDataSource] GetTotalSummaryAsync - Summaries: {options.SummaryInfo?.Count ?? 0}");
if (options.SummaryInfo == null || options.SummaryInfo.Count == 0 || _innerDataSource.Count == 0)
return Task.FromResult<IList>(new List<object?>());
var filteredData = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria);
var summaryValues = new List<object?>();
foreach (var summaryInfo in options.SummaryInfo)
{
var value = CalculateSummary(filteredData, summaryInfo);
summaryValues.Add(value);
}
_logger?.Debug($"[MgGridSignalRDataSource] Calculated {summaryValues.Count} summary values");
return Task.FromResult<IList>(summaryValues);
}
#endregion
#region Local Data Operations
/// <summary>
/// Gets items from local cache with filter, sort, and paging applied
/// </summary>
private List<TDataItem> GetLocalItems(GridCustomDataSourceItemsOptions options)
{
var data = _innerDataSource.ToList();
// Apply filter
var filtered = ApplyLocalFilter(data, options.FilterCriteria);
// Apply sorting
var sorted = ApplyLocalSort(filtered, options.SortInfo);
// Apply paging
var paged = sorted
.Skip(options.StartIndex)
.Take(options.Count)
.ToList();
return paged;
}
/// <summary>
/// Applies CriteriaOperator filter to local data using DevExpress CriteriaToExpressionConverter
/// </summary>
private List<TDataItem> ApplyLocalFilter(List<TDataItem> data, CriteriaOperator? criteria)
{
if (criteria is null || data.Count == 0)
return data;
try
{
// Use DevExpress built-in CriteriaToExpressionConverter
var filteredData = data.AsQueryable().AppendWhere(_criteriaConverter, criteria);
return filteredData.Cast<TDataItem>().ToList();
}
catch (Exception ex)
{
_logger?.Error($"[MgGridSignalRDataSource] Local filter failed: {ex.Message}", ex);
return data;
}
}
/// <summary>
/// Applies sorting to local data
/// </summary>
private List<TDataItem> ApplyLocalSort(List<TDataItem> data, IReadOnlyList<GridCustomDataSourceSortInfo>? sortInfo)
{
if (sortInfo == null || sortInfo.Count == 0 || data.Count == 0)
return data;
try
{
IOrderedEnumerable<TDataItem>? ordered = null;
for (var i = 0; i < sortInfo.Count; i++)
{
var sort = sortInfo[i];
var propertyInfo = typeof(TDataItem).GetProperty(sort.FieldName);
if (propertyInfo == null)
continue;
Func<TDataItem, object?> keySelector = item => propertyInfo.GetValue(item);
if (i == 0)
{
ordered = sort.DescendingSortOrder
? data.OrderByDescending(keySelector)
: data.OrderBy(keySelector);
}
else if (ordered != null)
{
ordered = sort.DescendingSortOrder
? ordered.ThenByDescending(keySelector)
: ordered.ThenBy(keySelector);
}
}
return ordered?.ToList() ?? data;
}
catch (Exception ex)
{
_logger?.Error($"[MgGridSignalRDataSource] Local sort failed: {ex.Message}", ex);
return data;
}
}
/// <summary>
/// Calculates a summary value for the given data
/// </summary>
private object? CalculateSummary(List<TDataItem> data, GridCustomDataSourceSummaryInfo summaryInfo)
{
if (data.Count == 0)
return null;
try
{
var propertyInfo = typeof(TDataItem).GetProperty(summaryInfo.FieldName);
return summaryInfo.SummaryType switch
{
GridSummaryItemType.Count => data.Count,
GridSummaryItemType.Sum when propertyInfo != null =>
data.Sum(item => Convert.ToDecimal(propertyInfo.GetValue(item) ?? 0)),
GridSummaryItemType.Min when propertyInfo != null =>
data.Min(item => propertyInfo.GetValue(item)),
GridSummaryItemType.Max when propertyInfo != null =>
data.Max(item => propertyInfo.GetValue(item)),
GridSummaryItemType.Avg when propertyInfo != null =>
data.Average(item => Convert.ToDecimal(propertyInfo.GetValue(item) ?? 0)),
_ => null
};
}
catch (Exception ex)
{
_logger?.Error($"[MgGridSignalRDataSource] Summary calculation failed: {ex.Message}", ex);
return null;
}
}
#endregion
#region Filter Criteria Tracking
/// <summary>
/// Gets a unique key for the filter criteria
/// </summary>
private string GetFilterKey(CriteriaOperator? criteria)
{
if (criteria is null)
return string.Empty;
try
{
return CriteriaOperator.ToString(criteria);
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Checks if this filter has been seen before
/// </summary>
private bool IsKnownFilter(string filterKey)
{
lock (_syncLock)
{
return _knownFilterCriteria.Contains(filterKey);
}
}
/// <summary>
/// Marks a filter as known (seen before)
/// </summary>
private void MarkFilterAsKnown(string filterKey)
{
lock (_syncLock)
{
_knownFilterCriteria.Add(filterKey);
}
}
/// <summary>
/// Clears the known filter cache
/// </summary>
public void ClearKnownFilters()
{
lock (_syncLock)
{
_knownFilterCriteria.Clear();
}
_logger?.Debug("[MgGridSignalRDataSource] Known filters cleared");
}
#endregion
#region Server Communication
/// <summary>
/// Loads data from server synchronously (blocking)
/// </summary>
private Task LoadFromServerAsync()
{
_logger?.Debug("[MgGridSignalRDataSource] Loading from server (sync)");
return _innerDataSource.LoadDataSource();
}
/// <summary>
/// Refreshes data in background using callback pattern (non-blocking)
/// </summary>
private Task RefreshInBackgroundAsync(string filterKey)
{
_logger?.Debug($"[MgGridSignalRDataSource] Starting background refresh for filter: {filterKey}");
// Use async callback version - this won't block
return _innerDataSource.LoadDataSourceAsync();
}
/// <summary>
/// Called when inner data source finishes loading
/// </summary>
private Task OnInnerDataSourceLoaded()
{
_logger?.Debug("[MgGridSignalRDataSource] Inner data source loaded, triggering refresh event");
OnBackgroundRefreshCompleted?.Invoke();
return Task.CompletedTask;
}
#endregion
#region Cleanup
/// <summary>
/// Invalidates all caches and clears known filters
/// </summary>
public void InvalidateCache()
{
ClearKnownFilters();
_logger?.Debug("[MgGridSignalRDataSource] Cache invalidated");
}
#endregion
}

View File

@ -0,0 +1,12 @@
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
namespace AyCode.Blazor.Components.Components.Grids
{
public class MgGridToolbarBase : DxToolbar
{
[Parameter] public IMgGridBase Grid { get; set; }
[Parameter] public Func<ToolbarItemClickEventArgs, Task> RefreshClick { get; set; }
[Parameter] public bool ShowOnlyIcon { get; set; } = false;
}
}

View File

@ -0,0 +1,6 @@
namespace AyCode.Blazor.Components.Components.Grids;
public class MgGridToolbarHelper
{
}

View File

@ -0,0 +1,183 @@
@using AyCode.Blazor.Components.Components.Grids
<MgGridToolbarBase @ref="GridToolbar" Grid="Grid" ItemRenderStyleMode="ToolbarRenderStyleMode.Plain" ShowOnlyIcon="ShowOnlyIcon">
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "New")" Click="NewItem_Click" IconCssClass="grid-new-row" Visible="@(!IsEditing)" Enabled="@(EnableNew && !IsSyncing)" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Edit")" Click="EditItem_Click" IconCssClass="grid-edit-row" Visible="@(!IsEditing)" Enabled="@(EnableEdit && HasFocusedRow && !IsSyncing)" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Delete")" Click="DeleteItem_Click" IconCssClass="grid-delete-row" Visible="@(!IsEditing)" Enabled="@(EnableDelete && HasFocusedRow && !IsSyncing)" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Save")" Click="SaveItem_Click" IconCssClass="grid-save" Visible="@IsEditing" RenderStyle="ButtonRenderStyle.Primary" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Cancel")" Click="CancelEdit_Click" IconCssClass="grid-cancel" Visible="@IsEditing" RenderStyle="ButtonRenderStyle.Secondary" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Prev Row")" BeginGroup="true" Click="PrevRow_Click" IconCssClass="grid-chevron-up" Enabled="@(HasFocusedRow && !IsSyncing && !IsEditing)" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Next Row")" Click="NextRow_Click" IconCssClass="grid-chevron-down" Enabled="@(HasFocusedRow && !IsSyncing && !IsEditing)" />
@if (!OnlyGridEditTools)
{
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Column Chooser")" BeginGroup="true" Click="ColumnChooserItem_Click" IconCssClass="grid-column-chooser" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Layout")" IconCssClass="grid-layout">
<Items>
<DxToolbarItem Text="Load Layout" Click="LoadLayout_Click" IconCssClass="grid-layout-load" Enabled="@_hasUserLayout" />
<DxToolbarItem Text="Save Layout" Click="SaveLayout_Click" IconCssClass="grid-layout-save" />
<DxToolbarItem BeginGroup="true" Text="Reset Layout" Click="ResetLayout_Click" IconCssClass="grid-layout-reset" />
</Items>
</DxToolbarItem>
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Export")" IconCssClass="grid-export" Visible="false" Enabled="@(HasFocusedRow && !IsEditing)">
<Items>
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "To CSV")" Click="ExportCsvItem_Click" IconCssClass="grid-export-xlsx" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "To XLSX")" Click="ExportXlsxItem_Click" IconCssClass="grid-export-xlsx" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "To XLS")" Click="ExportXlsItem_Click" IconCssClass="grid-export-xlsx" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "To PDF")" Click="ExportPdfItem_Click" IconCssClass="grid-export-pdf" />
</Items>
</DxToolbarItem>
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : "Reload data")" BeginGroup="true" Click="ReloadData_Click" IconCssClass="grid-refresh" Enabled="@(!IsSyncing && !_isReloadInProgress && !IsEditing)" />
<DxToolbarItem Text="@(ShowOnlyIcon ? "" : FullscreenButtonText)" Click="Fullscreen_Click" IconCssClass="@FullscreenIconCssClass" Enabled="@(!IsEditing)" />
@ToolbarItemsExtended
}
</MgGridToolbarBase>
@code {
[Parameter] public bool OnlyGridEditTools { get; set; } = false;
[Parameter] public IMgGridBase Grid { get; set; } = null!;
[Parameter] public RenderFragment? ToolbarItemsExtended { get; set; }
[Parameter] public EventCallback<ToolbarItemClickEventArgs> OnReloadDataClick { get; set; }
[Parameter] public bool ShowOnlyIcon { get; set; } = false;
[Parameter] public bool EnableNew { get; set; } = true;
[Parameter] public bool EnableEdit { get; set; } = true;
[Parameter] public bool EnableDelete { get; set; } = false;
public MgGridToolbarBase GridToolbar { get; set; } = null!;
const string ExportFileName = "ExportResult";
private bool _hasUserLayout;
private bool _isReloadInProgress;
/// <summary>
/// Whether the grid is currently in edit mode (New or Edit)
/// </summary>
private bool IsEditing => Grid?.GridEditState != MgGridEditState.None;
/// <summary>
/// Whether the grid is currently syncing data
/// </summary>
private bool IsSyncing => Grid?.IsSyncing ?? false;
/// <summary>
/// Whether there is a focused row in the grid
/// </summary>
private bool HasFocusedRow => Grid?.GetFocusedRowIndex() >= 0;
/// <summary>
/// Whether the grid is currently in fullscreen mode
/// </summary>
private bool IsFullscreenMode => Grid?.IsFullscreen ?? false;
/// <summary>
/// Button text for fullscreen toggle
/// </summary>
private string FullscreenButtonText => IsFullscreenMode ? "Exit Fullscreen" : "Fullscreen";
/// <summary>
/// Icon class for fullscreen toggle button
/// </summary>
private string FullscreenIconCssClass => IsFullscreenMode ? "grid-fullscreen-exit" : "grid-fullscreen";
protected override async Task OnInitializedAsync()
{
_hasUserLayout = await Grid.HasUserLayoutAsync();
}
async Task ReloadData_Click(ToolbarItemClickEventArgs e)
{
_isReloadInProgress = true;
try
{
await OnReloadDataClick.InvokeAsync(e);
}
finally
{
_isReloadInProgress = false;
}
}
async Task NewItem_Click()
{
await Grid.StartEditNewRowAsync();
}
async Task EditItem_Click()
{
await Grid.StartEditRowAsync(Grid.GetFocusedRowIndex());
}
void DeleteItem_Click()
{
Grid.ShowRowDeleteConfirmation(Grid.GetFocusedRowIndex());
}
async Task SaveItem_Click()
{
await Grid.SaveChangesAsync();
}
async Task CancelEdit_Click()
{
await Grid.CancelEditAsync();
}
void PrevRow_Click()
{
Grid.StepPrevRow();
}
void NextRow_Click()
{
Grid.StepNextRow();
}
void ColumnChooserItem_Click(ToolbarItemClickEventArgs e)
{
Grid.ShowColumnChooser();
}
void Fullscreen_Click()
{
Grid.ToggleFullscreen();
}
async Task ExportXlsxItem_Click()
{
await Grid.ExportToXlsxAsync(ExportFileName);
}
async Task ExportXlsItem_Click()
{
await Grid.ExportToXlsAsync(ExportFileName);
}
async Task ExportCsvItem_Click()
{
await Grid.ExportToCsvAsync(ExportFileName);
}
async Task ExportPdfItem_Click()
{
await Grid.ExportToPdfAsync(ExportFileName);
}
async Task LoadLayout_Click()
{
await Grid.LoadUserLayoutAsync();
}
async Task SaveLayout_Click()
{
await Grid.SaveUserLayoutAsync();
_hasUserLayout = true;
}
async Task ResetLayout_Click()
{
await Grid.ResetLayoutAsync();
}
}

View File

@ -0,0 +1,209 @@
@using DevExpress.Blazor
@inject Microsoft.JSInterop.IJSRuntime JSRuntime
<CascadingValue Value="this">
@if (_isFullscreen)
{
<div class="mg-fullscreen-overlay">
<div class="mg-fullscreen-header">
<span class="mg-fullscreen-title">@(_currentGrid?.Caption ?? "Grid")</span>
<button type="button" class="btn-close btn-close-white" aria-label="Close" @onclick="ExitFullscreen"></button>
</div>
<div class="mg-fullscreen-body">
@RenderMainContent()
</div>
</div>
}
else
{
@RenderMainContent()
}
</CascadingValue>
@code {
private IInfoPanelBase? _infoPanelInstance;
private IMgGridBase? _currentGrid;
private bool _isFullscreen;
private string _currentInfoPanelSize = "400px";
private bool _sizeLoaded;
/// <summary>
/// The grid content to display in the left pane
/// </summary>
[Parameter]
public RenderFragment? GridContent { get; set; }
/// <summary>
/// InfoPanel content (e.g., GridShippingDocumentInfoPanel) to display in the right pane.
/// If not set, the default MgGridInfoPanel is used.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Initial size of the InfoPanel pane. Default is "400px".
/// </summary>
[Parameter]
public string InfoPanelSize { get; set; } = "400px";
/// <summary>
/// Whether to show the InfoPanel. Default is true.
/// </summary>
[Parameter]
public bool ShowInfoPanel { get; set; } = true;
/// <summary>
/// Whether the wrapper is currently in fullscreen mode
/// </summary>
public bool IsFullscreen => _isFullscreen;
/// <summary>
/// Gets or sets the InfoPanel instance for grid-InfoPanel communication
/// </summary>
public IInfoPanelBase? InfoPanelInstance
{
get => _infoPanelInstance;
set => _infoPanelInstance = value;
}
/// <summary>
/// Registers an InfoPanel instance (called by child InfoPanel components)
/// </summary>
public void RegisterInfoPanel(IInfoPanelBase infoPanel)
{
_infoPanelInstance = infoPanel;
}
/// <summary>
/// Registers the grid instance (called by MgGridBase)
/// </summary>
public void RegisterGrid(IMgGridBase grid)
{
_currentGrid = grid;
// Load saved size when grid is registered
if (!_sizeLoaded)
{
_ = LoadSavedSizeAsync();
}
}
/// <summary>
/// Toggles fullscreen mode
/// </summary>
public void ToggleFullscreen()
{
_isFullscreen = !_isFullscreen;
StateHasChanged();
}
/// <summary>
/// Enters fullscreen mode
/// </summary>
public void EnterFullscreen()
{
_isFullscreen = true;
StateHasChanged();
}
/// <summary>
/// Exits fullscreen mode
/// </summary>
public void ExitFullscreen()
{
_isFullscreen = false;
StateHasChanged();
}
private string GetStorageKey() => _currentGrid != null
? $"Splitter_{_currentGrid.AutomaticLayoutStorageKey}"
: null!;
private async Task LoadSavedSizeAsync()
{
if (_currentGrid == null) return;
try
{
var storageKey = GetStorageKey();
var savedSize = await JSRuntime.InvokeAsync<string>("localStorage.getItem", storageKey);
if (!string.IsNullOrWhiteSpace(savedSize))
{
_currentInfoPanelSize = savedSize;
_sizeLoaded = true;
await InvokeAsync(StateHasChanged);
}
else
{
_currentInfoPanelSize = InfoPanelSize;
_sizeLoaded = true;
}
}
catch
{
// Mute exceptions for the server prerender stage
_currentInfoPanelSize = InfoPanelSize;
_sizeLoaded = true;
}
}
private async Task SaveSizeAsync(string size)
{
if (_currentGrid == null) return;
try
{
var storageKey = GetStorageKey();
await JSRuntime.InvokeVoidAsync("localStorage.setItem", storageKey, size);
}
catch
{
// Mute exceptions for the server prerender stage
}
}
private async Task OnInfoPanelSizeChanged(string newSize)
{
_currentInfoPanelSize = newSize;
await SaveSizeAsync(newSize);
}
protected override void OnParametersSet()
{
if (!_sizeLoaded)
{
_currentInfoPanelSize = InfoPanelSize;
}
base.OnParametersSet();
}
private RenderFragment RenderMainContent() => __builder =>
{
if (ShowInfoPanel)
{
<DxSplitter Width="100%" Height="@(_isFullscreen ? "100%" : null)" CssClass="mg-grid-with-info-panel" Orientation="Orientation.Horizontal">
<Panes>
<DxSplitterPane>
@GridContent
</DxSplitterPane>
<DxSplitterPane Size="@_currentInfoPanelSize" MinSize="0px" MaxSize="100%" AllowCollapse="true" CssClass="mg-info-panel-pane"
SizeChanged="OnInfoPanelSizeChanged">
@if (ChildContent != null)
{
@ChildContent
}
else
{
<MgGridInfoPanel />
}
</DxSplitterPane>
</Panes>
</DxSplitter>
}
else
{
@GridContent
}
};
}

View File

@ -0,0 +1,15 @@
# Grids
Core grid system built on DevExpress `DxGrid`. For the full technical reference see `AyCode.Blazor.Components/docs/MGGRID/README.md`.
## Key Files
- **`MgGridBase.cs`** — `MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>`, the main abstract grid component. Extends `DxGrid` with SignalR CRUD, layout persistence, master-detail hierarchy, edit state tracking, fullscreen toggle.
- **`MgGridWithInfoPanel.razor`** — `DxSplitter` wrapper: grid (left) + InfoPanel (right), fullscreen overlay, splitter size persistence.
- **`MgGridToolbarBase.cs`** — Extends `DxToolbar` with `Grid`, `RefreshClick`, and `ShowOnlyIcon` parameters.
- **`MgGridToolbarTemplate.razor`** — Full toolbar template: New/Edit/Delete/Save/Cancel, row navigation, layout menu (Load/Save/Reset), column chooser, export, reload, fullscreen. Extensible via `ToolbarItemsExtended`.
- **`MgGridDataColumn.cs`** — Extends `DxGridDataColumn` with InfoPanel parameters (`ShowInInfoPanel`, `InfoPanelOrder`, `InfoPanelDisplayFormat`) and `UrlLink` template with `{Property}` placeholder substitution via compiled accessors.
- **`MgGridInfoPanel.razor`** / **`.razor.cs`** — `MgGridInfoPanel` implementing `IInfoPanelBase`. Responsive column layout (1-4 columns with breakpoints), edit/view mode with typed editors, template system, sticky positioning via JS interop.
- **`MgGridSignalRDataSource.cs`** — `GridCustomDataSource` wrapping `AcSignalRDataSource`. Local cache for seen filter criteria, background refresh.
- **`GridEditMode.cs`** — `MgGridEditState` enum: `None`, `New`, `Edit`.
- **`MgGridHelper.cs`**, **`MgGridToolbarHelper.cs`**, **`MgGridInfoPanelHelper.cs`** — Placeholder helpers (empty).

View File

@ -0,0 +1,6 @@
namespace AyCode.Blazor.Components.Components;
public class MgComponentsHelper
{
}

View File

@ -0,0 +1,183 @@
@using Microsoft.JSInterop
@inject IJSRuntime JS
<div @ref="_containerRef" class="@ContainerCssClass" style="@ContainerStyle">
@if (IsVisible || ForceRender)
{
@ChildContent
}
else if (PlaceholderContent != null)
{
@PlaceholderContent
}
else
{
<div class="lazy-content-placeholder" style="min-height: @MinHeight;">
@if (ShowLoadingIndicator)
{
<div class="text-center py-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Betöltés...</span>
</div>
</div>
}
</div>
}
</div>
@code {
private ElementReference _containerRef;
private DotNetObjectReference<MgLazyLoadContent>? _dotNetRef;
private bool _isObserverInitialized;
/// <summary>
/// Content to render when visible
/// </summary>
[Parameter, EditorRequired]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Optional placeholder content to show before the element becomes visible
/// </summary>
[Parameter]
public RenderFragment? PlaceholderContent { get; set; }
/// <summary>
/// Root margin for IntersectionObserver (e.g., "100px" to load 100px before visible)
/// </summary>
[Parameter]
public string RootMargin { get; set; } = "50px";
/// <summary>
/// Threshold for IntersectionObserver (0.0 to 1.0)
/// </summary>
[Parameter]
public double Threshold { get; set; } = 0.01;
/// <summary>
/// Minimum height for the placeholder (prevents layout shift)
/// </summary>
[Parameter]
public string MinHeight { get; set; } = "100px";
/// <summary>
/// CSS class for the container
/// </summary>
[Parameter]
public string? ContainerCssClass { get; set; }
/// <summary>
/// Inline style for the container
/// </summary>
[Parameter]
public string? ContainerStyle { get; set; }
/// <summary>
/// Force render regardless of visibility (useful for disabling lazy loading)
/// </summary>
[Parameter]
public bool ForceRender { get; set; }
/// <summary>
/// Show a loading spinner in the placeholder
/// </summary>
[Parameter]
public bool ShowLoadingIndicator { get; set; } = true;
/// <summary>
/// Callback when content becomes visible
/// </summary>
[Parameter]
public EventCallback OnContentVisible { get; set; }
/// <summary>
/// Gets whether the content is currently visible
/// </summary>
public bool IsVisible { get; private set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !ForceRender)
{
await InitializeObserverAsync();
}
}
private async Task InitializeObserverAsync()
{
if (_isObserverInitialized) return;
try
{
_dotNetRef = DotNetObjectReference.Create(this);
// Initialize observer and check immediate visibility
var isCurrentlyVisible = await JS.InvokeAsync<bool>(
"lazyContentObserver.observe",
_containerRef,
_dotNetRef,
RootMargin,
Threshold);
_isObserverInitialized = true;
// If already visible, trigger the callback immediately
if (isCurrentlyVisible && !IsVisible)
{
await OnVisibilityChanged(true);
}
}
catch (JSException ex)
{
Console.WriteLine($"MgLazyLoadContent: Failed to initialize observer: {ex.Message}");
// Fallback: render immediately if JS fails
IsVisible = true;
await OnContentVisible.InvokeAsync();
StateHasChanged();
}
}
[JSInvokable]
public async Task OnVisibilityChanged(bool isVisible)
{
if (IsVisible == isVisible) return;
IsVisible = isVisible;
if (IsVisible)
{
await OnContentVisible.InvokeAsync();
}
StateHasChanged();
}
/// <summary>
/// Manually triggers the OnContentVisible callback if the content is currently visible.
/// Useful when the content data changes but visibility hasn't changed.
/// </summary>
public async Task TriggerContentVisibleAsync()
{
if (IsVisible)
{
await OnContentVisible.InvokeAsync();
}
}
public async ValueTask DisposeAsync()
{
if (_isObserverInitialized)
{
try
{
await JS.InvokeVoidAsync("lazyContentObserver.unobserve", _containerRef);
}
catch
{
// Ignore errors during disposal
}
}
_dotNetRef?.Dispose();
}
}

View File

@ -0,0 +1,21 @@
# Components
DevExpress component wrappers and grid infrastructure for the AyCode Blazor component library. Each `Ac*` class extends a DevExpress Blazor control to allow project-wide customization from a single point.
## Key Files
- **`AcComponentBase.cs`** -- Abstract base class extending `DxComponentBase`.
- **`AcButton.cs`** -- Extends `DxButton`.
- **`AcTextBox.cs`** -- Extends `DxTextBox`.
- **`AcComboBox.cs`** -- Generic wrapper for `DxComboBox<TData, TValue>`.
- **`AcDxDateEdit.cs`** -- Generic wrapper for `DxDateEdit<T>`.
- **`AcFormLayoutItem.cs`** -- Extends `DxFormLayoutItem`.
- **`AcMaskedInput.cs`** -- Generic wrapper for `DxMaskedInput<T>`.
- **`AcMemo.cs`** -- Extends `DxMemo`.
- **`AcSpinEdit.cs`** -- Generic wrapper for `DxSpinEdit<T>`.
- **`MgComponentsHelper.cs`** -- Placeholder helper class (currently empty).
## Subfolders
- **`CardViews/`** -- Generic card-based view component with pagination.
- **`Grids/`** -- Core grid system with SignalR data binding, toolbar, info panel, and layout persistence.

View File

@ -0,0 +1,41 @@
# AyCode.Blazor.Components
@project {
type = "framework"
own-dep-projects = [
"AyCode.Core, AyCode.Entities, AyCode.Interfaces, AyCode.Models, AyCode.Services, AyCode.Services.Server, AyCode.Utils (in AyCode.Core repo)"
]
}
Blazor Razor component library targeting .NET 10. Provides reusable DevExpress-based UI components, a SignalR-powered grid system, and LINQ expression serialization services.
## Documentation
| Document | Topic |
|---|---|
| `MGGRID/README.md` | MgGrid system — overview, hierarchy, generic params, IMgGridBase interface |
| `MGGRID/MGGRID_PARAMETERS.md` | Component parameters, event callbacks, default grid settings |
| `MGGRID/MGGRID_CRUD.md` | Lifecycle, CRUD operations, edit flow, disposal |
| `MGGRID/MGGRID_LAYOUT.md` | Layout persistence (storage keys, tiers, operations) |
| `MGGRID/MGGRID_DETAIL.md` | Master-detail hierarchy |
| `MGGRID/MGGRID_RENDERING.md` | Fullscreen mode, rendering |
| `MGGRID/MGGRID_INFOPANEL.md` | MgGridInfoPanel, MgGridWithInfoPanel wrapper |
| `MGGRID/MGGRID_TOOLBAR.md` | MgGridToolbarTemplate (buttons, parameters, state) |
| `MGGRID/MGGRID_COLUMNS.md` | MgGridDataColumn (InfoPanel params, UrlLink) |
| `MGGRID/MGGRID_DATASOURCE.md` | MgGridSignalRDataSource (server-side data, local cache) |
## Dependencies
- **DevExpress.Blazor** 25.1.3, **DevExpress.Data** 25.1.3
- **Microsoft.AspNetCore.SignalR.Client** 9.0.11, **MessagePack** 3.1.4
- **Project refs:** AyCode.Blazor.Models, AyCode.Blazor.Models.Server
- **DLL refs:** AyCode.Core, AyCode.Entities, AyCode.Interfaces, AyCode.Models, AyCode.Services, AyCode.Services.Server, AyCode.Utils
## Key Files
- **`ExampleJsInterop.cs`** -- Scoped JS interop service that lazy-loads a JS module and exposes a `Prompt` method.
## Subfolders
- **`Components/`** -- DevExpress component wrappers and grid infrastructure.
- **`Services/`** -- Authentication, grid data source, and LINQ expression serialization helpers.

View File

@ -6,7 +6,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AyCode.Core.Interfaces;
using DevExpress.Data.Filtering.Helpers;
using DevExpress.Data.Linq;
using DevExpress.Data.Linq.Helpers;

View File

@ -1,13 +0,0 @@
using System.Collections.Concurrent;
using AyCode.Services.Logins;
namespace AyCode.Blazor.Components.Services;
public class AcSessionService<TSessionItem, TSessionItemId> where TSessionItem : IAcSessionItem<TSessionItemId> where TSessionItemId : notnull
{
public ConcurrentDictionary<TSessionItemId, TSessionItem> Sessions { get; private set; } = [];
public AcSessionService()
{
}
}

View File

@ -1,284 +0,0 @@
using System.Collections.Concurrent;
using AyCode.Core;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Loggers;
using AyCode.Interfaces.Entities;
using AyCode.Services.Loggers;
using AyCode.Services.SignalRs;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.SignalR.Client;
namespace AyCode.Blazor.Components.Services
{
public abstract class AcSignalRClientBase : IAcSignalRHubClient
{
private readonly ConcurrentDictionary<int, SignalRRequestModel> _responseByRequestId = new();
protected readonly HubConnection HubConnection;
protected readonly AcLoggerBase Logger;
public event Action<int, byte[], int?> OnMessageReceived = null!;
//public event Action<int, int> OnMessageRequested;
public int Timeout = 10000;
private const string TagsName = "SignalRTags";
protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger)
{
Logger = logger;
HubConnection = new HubConnectionBuilder()
.WithUrl(fullHubName)
//.AddMessagePackProtocol(options => {
// options.SerializerOptions = MessagePackSerializerOptions.Standard
// .WithResolver(MessagePack.Resolvers.StandardResolver.Instance)
// .WithSecurity(MessagePackSecurity.UntrustedData)
// .WithCompression(MessagePackCompression.Lz4Block)
// .WithCompressionMinLength(256);})
.Build();
HubConnection.Closed += HubConnection_Closed;
_ = HubConnection.On<int, byte[], int?>(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage);
//_ = HubConnection.On<int, int>(nameof(IAcSignalRHubClient.OnRequestMessage), OnRequestMessage);
HubConnection.StartAsync().Forget();
}
private Task HubConnection_Closed(Exception? arg)
{
if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed");
else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}");
_responseByRequestId.Clear();
return Task.CompletedTask;
}
public async Task StartConnection()
{
if (HubConnection.State == HubConnectionState.Disconnected)
await HubConnection.StartAsync();
if (HubConnection.State != HubConnectionState.Connected)
await TaskHelper.WaitToAsync(() => HubConnection.State == HubConnectionState.Connected, Timeout, 10, 25);
}
public async Task StopConnection()
{
await HubConnection.StopAsync();
await HubConnection.DisposeAsync();
}
public virtual Task SendMessageToServerAsync(int messageTag)
=> SendMessageToServerAsync(messageTag, null, AcDomain.NextUniqueInt32);
public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId)
{
Logger.DebugConditional($"Client SendMessageToServerAsync; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
return StartConnection().ContinueWith(_ =>
{
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, msgp, requestId);
});
}
#region CRUD
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object id) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), AcDomain.NextUniqueInt32);
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object id)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback);
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object[] ids) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), AcDomain.NextUniqueInt32);
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[] ids)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), responseCallback);
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag);
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
=> SendMessageToServerAsync(messageTag, null, responseCallback);
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[]? contextParams)
=> SendMessageToServerAsync(messageTag, (contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams))), responseCallback);
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag, object[]? contextParams) //where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), AcDomain.NextUniqueInt32);
public virtual Task<TPostData?> PostDataAsync<TPostData>(int messageTag, TPostData postData) where TPostData : class
=> SendMessageToServerAsync<TPostData>(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), AcDomain.NextUniqueInt32);
public virtual Task<TResponseData?> PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData) //where TPostData : class where TResponseData : class
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), AcDomain.NextUniqueInt32);
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TPostData?>, Task> responseCallback) //where TPostData : class
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), responseCallback);
public virtual Task PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback) //where TPostData : class where TResponseData : class
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<TPostData>(postData), responseCallback);
public Task GetAllIntoAsync<TResponseItem>(List<TResponseItem> intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid
{
return GetAllAsync<List<TResponseItem>>(messageTag, response =>
{
var logText = $"GetAllIntoAsync<{typeof(TResponseItem).Name}>(); status: {response.Status}; dataCount: {response.ResponseData?.Count}; {ConstHelper.NameByValue(TagsName, messageTag)};";
intoList.Clear();
if (response.Status == SignalResponseStatus.Success && response.ResponseData != null)
{
Logger.Debug(logText);
intoList.AddRange(response.ResponseData);
}
else Logger.Error(logText);
callback?.Invoke();
return Task.CompletedTask;
}, contextParams);
}
#endregion CRUD
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag) //where TResponse : class
=> SendMessageToServerAsync<TResponse>(messageTag, null, AcDomain.NextUniqueInt32);
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message) //where TResponse : class
=> SendMessageToServerAsync<TResponse>(messageTag, message, AcDomain.NextUniqueInt32);
protected virtual async Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message, int requestId) //where TResponse : class
{
Logger.DebugConditional($"Client SendMessageToServerAsync<TResult>; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
_responseByRequestId[requestId] = new SignalRRequestModel();
await SendMessageToServerAsync(messageTag, message, requestId);
try
{
if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, Timeout, 25, 50) &&
_responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage<string> responseMessage)
{
if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null)
{
var errorText = $"Client SendMessageToServerAsync<TResponseData> response error; await; tag: {messageTag}; Status: {responseMessage.Status}; requestId: {requestId};";
Logger.Error(errorText);
//TODO: Ideiglenes, majd a ResponseMessage-et kell visszaadni a Status miatt! - J.
return await Task.FromException<TResponse>(new Exception(errorText));
//throw new Exception(errorText);
//return default;
}
return responseMessage.ResponseData.JsonTo<TResponse>();
}
}
catch (Exception ex)
{
Logger.Error($"SendMessageToServerAsync; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
}
_responseByRequestId.TryRemove(requestId, out _);
return default;
}
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
=> SendMessageToServerAsync(messageTag, null, responseCallback);
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, ISignalRMessage? message, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
{
if (messageTag == 0)
Logger.Error($"SendMessageToServerAsync; messageTag == 0");
var requestId = AcDomain.NextUniqueInt32;
_responseByRequestId[requestId] = new SignalRRequestModel(new Action<ISignalResponseMessage<string>>(responseMessage =>
{
TResponseData? responseData = default;
if (responseMessage.Status == SignalResponseStatus.Success)
{
responseData = string.IsNullOrEmpty(responseMessage.ResponseData) ? default : responseMessage.ResponseData.JsonTo<TResponseData?>();
}
else Logger.Error($"Client SendMessageToServerAsync<TResponseData> response error; callback; Status: {responseMessage.Status}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
responseCallback(new SignalResponseMessage<TResponseData?>(messageTag, responseMessage.Status, responseData));
}));
return SendMessageToServerAsync(messageTag, message, requestId);
}
public virtual Task OnReceiveMessage(int messageTag, byte[] message, int? requestId)
{
var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}";
if (message.Length == 0) Logger.Warning($"message.Length == 0! {logText}");
try
{
if (requestId.HasValue && _responseByRequestId.ContainsKey(requestId.Value))
{
var reqId = requestId.Value;
_responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow;
Logger.Info($"[{_responseByRequestId[reqId].ResponseDateTime.Subtract(_responseByRequestId[reqId].RequestDateTime).TotalMilliseconds:N0}ms][{(message.Length/1024)}kb]{logText}");
var responseMessage = message.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
switch (_responseByRequestId[reqId].ResponseByRequestId)
{
case null:
_responseByRequestId[reqId].ResponseByRequestId = responseMessage;
return Task.CompletedTask;
case Action<ISignalResponseMessage<string>> messagePackCallback:
_responseByRequestId.TryRemove(reqId, out _);
messagePackCallback.Invoke(responseMessage);
return Task.CompletedTask;
//case Action<string> jsonCallback:
// _responseByRequestId.TryRemove(reqId, out _);
// jsonCallback.Invoke(responseMessage);
// return Task.CompletedTask;
default:
Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}");
break;
}
_responseByRequestId.TryRemove(reqId, out _);
}
else Logger.Info(logText);
OnMessageReceived(messageTag, message, requestId);
}
catch (Exception ex)
{
if (requestId.HasValue)
_responseByRequestId.TryRemove(requestId.Value, out _);
Logger.Error($"Client OnReceiveMessage; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
throw;
}
return Task.CompletedTask;
}
//public virtual Task OnRequestMessage(int messageTag, int requestId)
//{
// Logger.DebugConditional($"Client OnRequestMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId};");
// try
// {
// OnMessageRequested(messageTag, requestId);
// }
// catch(Exception ex)
// {
// Logger.Error($"Client OnReceiveMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId}; {ex.Message}", ex);
// throw;
// }
// return Task.CompletedTask;
//}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,356 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
/// <summary>
/// Deserializes AcExpressionNode DTO back to Expression tree.
/// </summary>
public class AcExpressionDeserializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
private readonly Dictionary<int, ParameterExpression> _parameters = new();
/// <summary>
/// Deserializes JSON to Expression.
/// </summary>
public static Expression ExpressionFromJson(string json, Type? entityType = null)
{
var node = JsonSerializer.Deserialize<AcExpressionNode>(json, JsonOptions)
?? throw new ArgumentException("Invalid expression JSON", nameof(json));
var deserializer = new AcExpressionDeserializer();
return deserializer.Deserialize(node, entityType);
}
/// <summary>
/// Deserializes JSON to typed Expression.
/// </summary>
public static Expression<Func<T, TResult>> ExpressionFromJson<T, TResult>(string json)
{
var expression = ExpressionFromJson(json, typeof(T));
return (Expression<Func<T, TResult>>)expression;
}
/// <summary>
/// Deserializes AcExpressionNode to Expression.
/// </summary>
public Expression Deserialize(AcExpressionNode node, Type? entityType = null)
{
return node.NodeType switch
{
ExpressionType.Lambda => DeserializeLambda(node, entityType),
ExpressionType.Parameter => DeserializeParameter(node),
ExpressionType.Constant => DeserializeConstant(node),
ExpressionType.MemberAccess => DeserializeMemberAccess(node, entityType),
ExpressionType.Call => DeserializeMethodCall(node, entityType),
ExpressionType.Conditional => DeserializeConditional(node, entityType),
ExpressionType.New => DeserializeNew(node, entityType),
ExpressionType.MemberInit => DeserializeMemberInit(node, entityType),
ExpressionType.NewArrayInit or ExpressionType.NewArrayBounds => DeserializeNewArray(node, entityType),
ExpressionType.Invoke => DeserializeInvocation(node, entityType),
ExpressionType.TypeIs or ExpressionType.TypeAs => DeserializeTypeBinary(node, entityType),
// Unary expressions
ExpressionType.Not or ExpressionType.Negate or ExpressionType.NegateChecked or
ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.ArrayLength or
ExpressionType.Quote or ExpressionType.UnaryPlus
=> DeserializeUnary(node, entityType),
// Binary expressions
ExpressionType.Add or ExpressionType.AddChecked or ExpressionType.Subtract or
ExpressionType.SubtractChecked or ExpressionType.Multiply or ExpressionType.MultiplyChecked or
ExpressionType.Divide or ExpressionType.Modulo or ExpressionType.Power or
ExpressionType.And or ExpressionType.AndAlso or ExpressionType.Or or ExpressionType.OrElse or
ExpressionType.ExclusiveOr or ExpressionType.Equal or ExpressionType.NotEqual or
ExpressionType.LessThan or ExpressionType.LessThanOrEqual or
ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual or
ExpressionType.Coalesce or ExpressionType.ArrayIndex or
ExpressionType.LeftShift or ExpressionType.RightShift
=> DeserializeBinary(node, entityType),
_ => throw new NotSupportedException($"Expression type '{node.NodeType}' is not supported.")
};
}
#region Deserialize Methods
private LambdaExpression DeserializeLambda(AcExpressionNode node, Type? entityType)
{
// Create parameters
var parameters = new List<ParameterExpression>();
if (node.Parameters != null)
{
foreach (var paramNode in node.Parameters)
{
var paramType = entityType ?? ResolveType(paramNode.TypeName);
var param = Expression.Parameter(paramType, paramNode.Name);
_parameters[paramNode.Index] = param;
parameters.Add(param);
// Use entityType only for first parameter
entityType = null;
}
}
var body = Deserialize(node.Body!, null);
return Expression.Lambda(body, parameters);
}
private ParameterExpression DeserializeParameter(AcExpressionNode node)
{
if (node.ParameterIndex.HasValue && _parameters.TryGetValue(node.ParameterIndex.Value, out var param))
return param;
throw new InvalidOperationException($"Parameter '{node.ParameterName}' not found.");
}
private static ConstantExpression DeserializeConstant(AcExpressionNode node)
{
var type = ResolveType(node.TypeName ?? "System.Object");
if (node.Value == null)
return Expression.Constant(null, type);
var value = JsonSerializer.Deserialize(node.Value, type, JsonOptions);
return Expression.Constant(value, type);
}
private Expression DeserializeMemberAccess(AcExpressionNode node, Type? entityType)
{
if (node.Object == null)
{
// Static member access
var declaringType = ResolveType(node.DeclaringType!);
var member = declaringType.GetMember(node.MemberName!, BindingFlags.Public | BindingFlags.Static).FirstOrDefault()
?? throw new InvalidOperationException($"Static member '{node.MemberName}' not found on type '{declaringType.Name}'.");
return Expression.MakeMemberAccess(null, member);
}
var obj = Deserialize(node.Object, entityType);
var memberInfo = obj.Type.GetMember(node.MemberName!, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase).FirstOrDefault()
?? throw new InvalidOperationException($"Member '{node.MemberName}' not found on type '{obj.Type.Name}'.");
return Expression.MakeMemberAccess(obj, memberInfo);
}
private Expression DeserializeMethodCall(AcExpressionNode node, Type? entityType)
{
var arguments = node.Arguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
var argumentTypes = arguments.Select(a => a.Type).ToArray();
var declaringType = node.DeclaringType != null ? ResolveType(node.DeclaringType) : null;
var instance = node.Object != null ? Deserialize(node.Object, entityType) : null;
MethodInfo? method = null;
if (instance != null)
{
// Instance method
method = FindMethod(instance.Type, node.MethodName!, argumentTypes, isStatic: false);
}
else if (declaringType != null)
{
// Static method (including extension methods)
method = FindMethod(declaringType, node.MethodName!, argumentTypes, isStatic: true);
}
if (method == null)
throw new InvalidOperationException($"Method '{node.MethodName}' not found.");
// Handle generic methods
if (method.IsGenericMethodDefinition && node.GenericArguments?.Count > 0)
{
var genericTypes = node.GenericArguments.Select(ResolveType).ToArray();
method = method.MakeGenericMethod(genericTypes);
}
return instance != null
? Expression.Call(instance, method, arguments)
: Expression.Call(method, arguments);
}
private Expression DeserializeBinary(AcExpressionNode node, Type? entityType)
{
var left = Deserialize(node.Left!, entityType);
var right = Deserialize(node.Right!, entityType);
// Handle type mismatches (e.g., nullable comparisons)
if (left.Type != right.Type)
{
if (Nullable.GetUnderlyingType(left.Type) == right.Type)
right = Expression.Convert(right, left.Type);
else if (Nullable.GetUnderlyingType(right.Type) == left.Type)
left = Expression.Convert(left, right.Type);
}
return Expression.MakeBinary(node.NodeType, left, right);
}
private Expression DeserializeUnary(AcExpressionNode node, Type? entityType)
{
var operand = Deserialize(node.Operand!, entityType);
var type = node.TypeName != null ? ResolveType(node.TypeName) : null;
return node.NodeType switch
{
ExpressionType.Convert or ExpressionType.ConvertChecked when type != null
=> Expression.Convert(operand, type),
_ => Expression.MakeUnary(node.NodeType, operand, type)
};
}
private Expression DeserializeConditional(AcExpressionNode node, Type? entityType)
{
var test = Deserialize(node.Test!, entityType);
var ifTrue = Deserialize(node.IfTrue!, entityType);
var ifFalse = Deserialize(node.IfFalse!, entityType);
return Expression.Condition(test, ifTrue, ifFalse);
}
private Expression DeserializeNew(AcExpressionNode node, Type? entityType)
{
var type = ResolveType(node.TypeName!);
var args = node.ConstructorArguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
var argTypes = args.Select(a => a.Type).ToArray();
var ctor = type.GetConstructor(argTypes)
?? throw new InvalidOperationException($"Constructor not found for type '{type.Name}'.");
return Expression.New(ctor, args);
}
private Expression DeserializeMemberInit(AcExpressionNode node, Type? entityType)
{
var type = ResolveType(node.TypeName!);
var args = node.ConstructorArguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
var argTypes = args.Select(a => a.Type).ToArray();
var ctor = type.GetConstructor(argTypes) ?? type.GetConstructor(Type.EmptyTypes)
?? throw new InvalidOperationException($"Constructor not found for type '{type.Name}'.");
var newExpr = Expression.New(ctor, args);
var bindings = node.MemberBindings?.Select(b => DeserializeMemberBinding(b, type, entityType)).ToList()
?? [];
return Expression.MemberInit(newExpr, bindings);
}
private MemberBinding DeserializeMemberBinding(MemberBindingNode node, Type declaringType, Type? entityType)
{
var member = declaringType.GetMember(node.MemberName, BindingFlags.Public | BindingFlags.Instance).FirstOrDefault()
?? throw new InvalidOperationException($"Member '{node.MemberName}' not found on type '{declaringType.Name}'.");
return node.BindingType switch
{
MemberBindingType.Assignment => Expression.Bind(member, Deserialize(node.Expression!, entityType)),
MemberBindingType.MemberBinding => Expression.MemberBind(member,
node.Bindings?.Select(b => DeserializeMemberBinding(b, GetMemberType(member), entityType)) ?? []),
MemberBindingType.ListBinding => Expression.ListBind(member,
node.Initializers?.Select(args => Expression.ElementInit(
GetAddMethod(GetMemberType(member)),
args.Select(a => Deserialize(a, entityType)))) ?? []),
_ => throw new NotSupportedException($"Binding type '{node.BindingType}' is not supported.")
};
}
private Expression DeserializeNewArray(AcExpressionNode node, Type? entityType)
{
var elementType = ResolveType(node.TypeName!).GetElementType()
?? throw new InvalidOperationException("Cannot determine array element type.");
var elements = node.Elements?.Select(e => Deserialize(e, entityType)).ToArray() ?? [];
return Expression.NewArrayInit(elementType, elements);
}
private Expression DeserializeInvocation(AcExpressionNode node, Type? entityType)
{
var expression = Deserialize(node.Object!, entityType);
var arguments = node.Arguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
return Expression.Invoke(expression, arguments);
}
private Expression DeserializeTypeBinary(AcExpressionNode node, Type? entityType)
{
var expression = Deserialize(node.Operand!, entityType);
var type = ResolveType(node.TypeName!);
return node.NodeType == ExpressionType.TypeIs
? Expression.TypeIs(expression, type)
: Expression.TypeAs(expression, type);
}
#endregion
#region Helper Methods
private static MethodInfo? FindMethod(Type type, string methodName, Type[] argumentTypes, bool isStatic)
{
var bindingFlags = BindingFlags.Public | (isStatic ? BindingFlags.Static : BindingFlags.Instance);
// Try exact match first
var method = type.GetMethod(methodName, bindingFlags, null, argumentTypes, null);
if (method != null) return method;
// Try finding by name and parameter count
var candidates = type.GetMethods(bindingFlags)
.Where(m => m.Name == methodName && m.GetParameters().Length == argumentTypes.Length)
.ToList();
return candidates.FirstOrDefault();
}
private static Type GetMemberType(MemberInfo member) => member switch
{
PropertyInfo pi => pi.PropertyType,
FieldInfo fi => fi.FieldType,
_ => throw new InvalidOperationException($"Cannot get type for member '{member.Name}'.")
};
private static MethodInfo GetAddMethod(Type collectionType)
{
return collectionType.GetMethod("Add")
?? throw new InvalidOperationException($"Add method not found on type '{collectionType.Name}'.");
}
private static Type ResolveType(string typeName)
{
var type = typeName switch
{
"System.String" or "string" => typeof(string),
"System.Int32" or "int" => typeof(int),
"System.Int64" or "long" => typeof(long),
"System.Int16" or "short" => typeof(short),
"System.Byte" or "byte" => typeof(byte),
"System.Boolean" or "bool" => typeof(bool),
"System.Double" or "double" => typeof(double),
"System.Single" or "float" => typeof(float),
"System.Decimal" or "decimal" => typeof(decimal),
"System.DateTime" => typeof(DateTime),
"System.DateTimeOffset" => typeof(DateTimeOffset),
"System.DateOnly" => typeof(DateOnly),
"System.TimeOnly" => typeof(TimeOnly),
"System.TimeSpan" => typeof(TimeSpan),
"System.Guid" => typeof(Guid),
"System.Object" or "object" => typeof(object),
_ => Type.GetType(typeName)
};
if (type == null && typeName.Contains("Nullable"))
{
var match = System.Text.RegularExpressions.Regex.Match(typeName, @"System\.Nullable`1\[\[(.+?),");
if (match.Success)
{
var underlyingType = ResolveType(match.Groups[1].Value);
type = typeof(Nullable<>).MakeGenericType(underlyingType);
}
}
return type ?? throw new InvalidOperationException($"Cannot resolve type '{typeName}'.");
}
#endregion
}

View File

@ -0,0 +1,238 @@
using System.Linq.Expressions;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
/// <summary>
/// Helper class for serializing and deserializing Expression trees and IQueryable queries.
/// Uses visitor pattern to handle all expression types automatically.
/// </summary>
public static class AcExpressionHelper
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
#region Expression Serialization
/// <summary>
/// Serializes an Expression to AcExpressionNode DTO.
/// </summary>
public static AcExpressionNode ExpressionToNode(Expression expression)
{
var visitor = new AcExpressionSerializerVisitor();
return visitor.Convert(expression);
}
/// <summary>
/// Serializes an Expression to JSON string.
/// </summary>
public static string ExpressionToJson(Expression expression)
{
var node = ExpressionToNode(expression);
return JsonSerializer.Serialize(node, JsonOptions);
}
/// <summary>
/// Serializes a typed Expression to JSON string.
/// </summary>
public static string ExpressionToJson<TEntity, TResult>(Expression<Func<TEntity, TResult>> expression)
{
return ExpressionToJson((Expression)expression);
}
#endregion
#region Expression Deserialization
/// <summary>
/// Deserializes AcExpressionNode DTO to Expression.
/// </summary>
public static Expression ExpressionFromNode(AcExpressionNode node, Type? entityType = null)
{
var deserializer = new AcExpressionDeserializer();
return deserializer.Deserialize(node, entityType);
}
/// <summary>
/// Deserializes JSON to Expression.
/// </summary>
public static Expression ExpressionFromJson(string json, Type? entityType = null)
{
return AcExpressionDeserializer.ExpressionFromJson(json, entityType);
}
/// <summary>
/// Deserializes JSON to typed Expression.
/// </summary>
public static Expression<Func<TEntity, TResult>> ExpressionFromJson<TEntity, TResult>(string json)
{
return AcExpressionDeserializer.ExpressionFromJson<TEntity, TResult>(json);
}
#endregion
#region IQueryable Serialization
/// <summary>
/// Serializes an IQueryable's expression tree to JSON.
/// </summary>
public static string QueryToJson<T>(IQueryable<T> query)
{
return ExpressionToJson(query.Expression);
}
/// <summary>
/// Serializes an IQueryable's expression tree to AcExpressionNode.
/// </summary>
public static AcExpressionNode QueryToNode<T>(IQueryable<T> query)
{
return ExpressionToNode(query.Expression);
}
#endregion
#region IQueryable Deserialization
/// <summary>
/// Applies a serialized query expression to an IQueryable source.
/// </summary>
public static IQueryable<T> ApplyQueryFromJson<T>(IQueryable<T> source, string json)
{
var node = JsonSerializer.Deserialize<AcExpressionNode>(json, JsonOptions)
?? throw new ArgumentException("Invalid query JSON", nameof(json));
return ApplyQueryFromNode(source, node);
}
/// <summary>
/// Applies an AcExpressionNode query to an IQueryable source.
/// </summary>
public static IQueryable<T> ApplyQueryFromNode<T>(IQueryable<T> source, AcExpressionNode node)
{
// If the node is a method call (Where, OrderBy, etc.), we need to rebuild it
// with the source expression replaced
var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
return source.Provider.CreateQuery<T>(expression);
}
/// <summary>
/// Rebuilds a query expression, replacing the source with the provided expression.
/// </summary>
private static Expression RebuildQueryExpression(Expression sourceExpression, AcExpressionNode node, Type entityType)
{
if (node is { NodeType: ExpressionType.Call, MethodName: not null })
{
return RebuildMethodCallChain(sourceExpression, node, entityType);
}
// If it's just a lambda (filter expression), wrap it in a Where call
if (node.NodeType == ExpressionType.Lambda)
{
var deserializer = new AcExpressionDeserializer();
var lambda = deserializer.Deserialize(node, entityType);
var whereMethod = typeof(Queryable).GetMethods()
.First(m => m.Name == "Where" && m.GetParameters().Length == 2)
.MakeGenericMethod(entityType);
return Expression.Call(whereMethod, sourceExpression, lambda);
}
throw new NotSupportedException($"Cannot apply expression of type '{node.NodeType}' to IQueryable.");
}
/// <summary>
/// Rebuilds a chain of method calls (Where, OrderBy, Skip, Take, etc.)
/// </summary>
private static Expression RebuildMethodCallChain(Expression sourceExpression, AcExpressionNode node, Type entityType)
{
// First, process the inner expression (the source of this method call)
Expression currentSource;
if (node.Arguments?.Count > 0 && node.Arguments[0].NodeType == ExpressionType.Call)
{
// Recursive: rebuild the inner chain first
currentSource = RebuildMethodCallChain(sourceExpression, node.Arguments[0], entityType);
}
else
{
// Base case: use the provided source
currentSource = sourceExpression;
}
// Now apply this method call
var methodName = node.MethodName!;
var declaringType = node.DeclaringType != null ? Type.GetType(node.DeclaringType) : typeof(Queryable);
// Find the method
var method = FindQueryableMethod(declaringType!, methodName, node.GenericArguments, node.Arguments?.Count ?? 1);
if (method == null)
throw new InvalidOperationException($"Method '{methodName}' not found.");
// Apply generic type arguments
if (method.IsGenericMethodDefinition && node.GenericArguments?.Count > 0)
{
var genericTypes = node.GenericArguments.Select(t => Type.GetType(t) ?? entityType).ToArray();
method = method.MakeGenericMethod(genericTypes);
}
// Build arguments
var deserializer = new AcExpressionDeserializer();
var arguments = new List<Expression> { currentSource };
// Skip first argument (it's the source) and deserialize the rest
if (node.Arguments?.Count > 1)
{
for (var i = 1; i < node.Arguments.Count; i++)
{
var argNode = node.Arguments[i];
if (argNode.NodeType == ExpressionType.Quote && argNode.Operand != null)
{
// Quoted lambda - unquote and deserialize
var lambda = deserializer.Deserialize(argNode.Operand, entityType);
arguments.Add(Expression.Quote(lambda));
}
else if (argNode.NodeType == ExpressionType.Lambda)
{
var lambda = deserializer.Deserialize(argNode, entityType);
arguments.Add(Expression.Quote(lambda));
}
else
{
arguments.Add(deserializer.Deserialize(argNode, entityType));
}
}
}
return Expression.Call(method, arguments);
}
private static System.Reflection.MethodInfo? FindQueryableMethod(Type declaringType, string methodName, List<string>? genericArgs, int argCount)
{
return declaringType.GetMethods()
.Where(m => m.Name == methodName)
.FirstOrDefault(m =>
{
var parameters = m.GetParameters();
if (parameters.Length != argCount) return false;
// Check if generic argument count matches
if (m.IsGenericMethodDefinition)
{
var genericCount = genericArgs?.Count ?? 1;
if (m.GetGenericArguments().Length != genericCount) return false;
}
return true;
});
}
#endregion
}

View File

@ -0,0 +1,214 @@
using System.Linq.Expressions;
using System.Text.Json.Serialization;
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
/// <summary>
/// Universal DTO representing any Expression node.
/// Recursively represents the entire Expression tree.
/// Serializable to JSON for transport over SignalR or HTTP.
/// </summary>
public sealed class AcExpressionNode
{
/// <summary>
/// The expression node type (Add, Equal, Call, MemberAccess, Lambda, etc.)
/// </summary>
public ExpressionType NodeType { get; set; }
/// <summary>
/// The CLR type name of this expression's result.
/// </summary>
public string? TypeName { get; set; }
#region Binary Expressions (Add, Equal, AndAlso, OrElse, etc.)
/// <summary>
/// Left operand for binary expressions.
/// </summary>
public AcExpressionNode? Left { get; set; }
/// <summary>
/// Right operand for binary expressions.
/// </summary>
public AcExpressionNode? Right { get; set; }
#endregion
#region Unary Expressions (Not, Convert, Negate, etc.)
/// <summary>
/// Operand for unary expressions.
/// </summary>
public AcExpressionNode? Operand { get; set; }
#endregion
#region Lambda Expressions
/// <summary>
/// Body of lambda expression.
/// </summary>
public AcExpressionNode? Body { get; set; }
/// <summary>
/// Parameter definitions for lambda expressions.
/// </summary>
public List<ParameterNode>? Parameters { get; set; }
#endregion
#region Member Access
/// <summary>
/// Member/property/field name for MemberAccess expressions.
/// </summary>
public string? MemberName { get; set; }
/// <summary>
/// Object expression for member access or instance method calls.
/// </summary>
public AcExpressionNode? Object { get; set; }
/// <summary>
/// Declaring type for static members.
/// </summary>
public string? DeclaringType { get; set; }
#endregion
#region Method Call
/// <summary>
/// Method name for Call expressions.
/// </summary>
public string? MethodName { get; set; }
/// <summary>
/// Arguments for method calls.
/// </summary>
public List<AcExpressionNode>? Arguments { get; set; }
/// <summary>
/// Generic type arguments for generic method calls.
/// </summary>
public List<string>? GenericArguments { get; set; }
#endregion
#region Constant
/// <summary>
/// Serialized constant value (JSON).
/// </summary>
public string? Value { get; set; }
#endregion
#region Parameter
/// <summary>
/// Parameter name for Parameter expressions.
/// </summary>
public string? ParameterName { get; set; }
/// <summary>
/// Parameter index (for matching parameters in lambda).
/// </summary>
public int? ParameterIndex { get; set; }
#endregion
#region Conditional (Ternary)
/// <summary>
/// Test expression for conditional expressions.
/// </summary>
public AcExpressionNode? Test { get; set; }
/// <summary>
/// IfTrue branch for conditional expressions.
/// </summary>
public AcExpressionNode? IfTrue { get; set; }
/// <summary>
/// IfFalse branch for conditional expressions.
/// </summary>
public AcExpressionNode? IfFalse { get; set; }
#endregion
#region New Expression
/// <summary>
/// Constructor arguments for New expressions.
/// </summary>
public List<AcExpressionNode>? ConstructorArguments { get; set; }
/// <summary>
/// Member bindings for MemberInit expressions.
/// </summary>
public List<MemberBindingNode>? MemberBindings { get; set; }
#endregion
#region Array/Collection
/// <summary>
/// Elements for NewArrayInit expressions.
/// </summary>
public List<AcExpressionNode>? Elements { get; set; }
#endregion
}
/// <summary>
/// Represents a parameter definition in a lambda expression.
/// </summary>
public sealed class ParameterNode
{
/// <summary>
/// Parameter name.
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Parameter type name.
/// </summary>
public string TypeName { get; set; } = "";
/// <summary>
/// Parameter index in the lambda.
/// </summary>
public int Index { get; set; }
}
/// <summary>
/// Represents a member binding in MemberInit expressions.
/// </summary>
public sealed class MemberBindingNode
{
/// <summary>
/// The member name being bound.
/// </summary>
public string MemberName { get; set; } = "";
/// <summary>
/// The binding type (Assignment, MemberBinding, ListBinding).
/// </summary>
public MemberBindingType BindingType { get; set; }
/// <summary>
/// The expression being assigned (for Assignment bindings).
/// </summary>
public AcExpressionNode? Expression { get; set; }
/// <summary>
/// Nested bindings (for MemberMemberBinding).
/// </summary>
public List<MemberBindingNode>? Bindings { get; set; }
/// <summary>
/// Element initializers (for ListBinding).
/// </summary>
public List<List<AcExpressionNode>>? Initializers { get; set; }
}

View File

@ -0,0 +1,429 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
/// <summary>
/// Expression visitor that serializes an Expression tree to AcExpressionNode DTO.
/// Handles all common expression types recursively.
/// </summary>
public class AcExpressionSerializerVisitor : ExpressionVisitor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
private readonly Dictionary<ParameterExpression, int> _parameterIndexes = new();
private int _nextParameterIndex;
// Stack to collect converted nodes
private readonly Stack<AcExpressionNode> _nodeStack = new();
/// <summary>
/// Converts an Expression to an AcExpressionNode DTO.
/// </summary>
public AcExpressionNode Convert(Expression expression)
{
_nodeStack.Clear();
_parameterIndexes.Clear();
_nextParameterIndex = 0;
VisitAndConvert(expression);
return _nodeStack.Count != 1 ? throw new InvalidOperationException($"Expected 1 node on stack, found {_nodeStack.Count}") : _nodeStack.Pop();
}
/// <summary>
/// Serializes an Expression to JSON string.
/// </summary>
public string ToJson(Expression expression)
{
var node = Convert(expression);
return JsonSerializer.Serialize(node, JsonOptions);
}
private void VisitAndConvert(Expression expression)
{
Visit(expression);
}
protected override Expression VisitBinary(BinaryExpression node)
{
VisitAndConvert(node.Left);
var left = _nodeStack.Pop();
VisitAndConvert(node.Right);
var right = _nodeStack.Pop();
_nodeStack.Push(new AcExpressionNode
{
NodeType = node.NodeType,
TypeName = node.Type.FullName,
Left = left,
Right = right
});
return node;
}
protected override Expression VisitUnary(UnaryExpression node)
{
VisitAndConvert(node.Operand);
var operand = _nodeStack.Pop();
_nodeStack.Push(new AcExpressionNode
{
NodeType = node.NodeType,
TypeName = node.Type.FullName,
Operand = operand
});
return node;
}
protected override Expression VisitLambda<T>(Expression<T> node)
{
// Register parameters with indexes
var parameters = new List<ParameterNode>();
foreach (var param in node.Parameters)
{
var index = _nextParameterIndex++;
_parameterIndexes[param] = index;
parameters.Add(new ParameterNode
{
Name = param.Name ?? $"p{index}",
TypeName = param.Type.FullName ?? param.Type.Name,
Index = index
});
}
VisitAndConvert(node.Body);
var body = _nodeStack.Pop();
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.Lambda,
TypeName = node.Type.FullName,
Body = body,
Parameters = parameters
});
return node;
}
protected override Expression VisitParameter(ParameterExpression node)
{
var index = _parameterIndexes.GetValueOrDefault(node, -1);
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.Parameter,
TypeName = node.Type.FullName,
ParameterName = node.Name,
ParameterIndex = index
});
return node;
}
protected override Expression VisitMember(MemberExpression node)
{
// Check if this is a closure variable access (captured variable)
if (IsClosureAccess(node))
{
var value = EvaluateClosureValue(node);
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.Constant,
TypeName = node.Type.FullName,
Value = SerializeValue(value)
});
return node;
}
AcExpressionNode? objectNode = null;
if (node.Expression != null)
{
VisitAndConvert(node.Expression);
objectNode = _nodeStack.Pop();
}
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.MemberAccess,
TypeName = node.Type.FullName,
MemberName = node.Member.Name,
Object = objectNode,
DeclaringType = node.Member.DeclaringType?.FullName
});
return node;
}
protected override Expression VisitConstant(ConstantExpression node)
{
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.Constant,
TypeName = node.Type.FullName,
Value = SerializeValue(node.Value)
});
return node;
}
protected override Expression VisitMethodCall(MethodCallExpression node)
{
AcExpressionNode? objectNode = null;
if (node.Object != null)
{
VisitAndConvert(node.Object);
objectNode = _nodeStack.Pop();
}
var arguments = new List<AcExpressionNode>();
foreach (var arg in node.Arguments)
{
VisitAndConvert(arg);
arguments.Add(_nodeStack.Pop());
}
var genericArgs = node.Method.IsGenericMethod
? node.Method.GetGenericArguments().Select(t => t.FullName ?? t.Name).ToList()
: null;
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.Call,
TypeName = node.Type.FullName,
MethodName = node.Method.Name,
Object = objectNode,
Arguments = arguments,
DeclaringType = node.Method.DeclaringType?.FullName,
GenericArguments = genericArgs
});
return node;
}
protected override Expression VisitConditional(ConditionalExpression node)
{
VisitAndConvert(node.Test);
var test = _nodeStack.Pop();
VisitAndConvert(node.IfTrue);
var ifTrue = _nodeStack.Pop();
VisitAndConvert(node.IfFalse);
var ifFalse = _nodeStack.Pop();
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.Conditional,
TypeName = node.Type.FullName,
Test = test,
IfTrue = ifTrue,
IfFalse = ifFalse
});
return node;
}
protected override Expression VisitNew(NewExpression node)
{
var arguments = new List<AcExpressionNode>();
foreach (var arg in node.Arguments)
{
VisitAndConvert(arg);
arguments.Add(_nodeStack.Pop());
}
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.New,
TypeName = node.Type.FullName,
ConstructorArguments = arguments
});
return node;
}
protected override Expression VisitMemberInit(MemberInitExpression node)
{
var arguments = new List<AcExpressionNode>();
foreach (var arg in node.NewExpression.Arguments)
{
VisitAndConvert(arg);
arguments.Add(_nodeStack.Pop());
}
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.MemberInit,
TypeName = node.Type.FullName,
ConstructorArguments = arguments,
MemberBindings = node.Bindings.Select(ConvertMemberBinding).ToList()
});
return node;
}
protected override Expression VisitNewArray(NewArrayExpression node)
{
var elements = new List<AcExpressionNode>();
foreach (var expr in node.Expressions)
{
VisitAndConvert(expr);
elements.Add(_nodeStack.Pop());
}
_nodeStack.Push(new AcExpressionNode
{
NodeType = node.NodeType,
TypeName = node.Type.FullName,
Elements = elements
});
return node;
}
protected override Expression VisitTypeBinary(TypeBinaryExpression node)
{
VisitAndConvert(node.Expression);
var operand = _nodeStack.Pop();
_nodeStack.Push(new AcExpressionNode
{
NodeType = node.NodeType,
TypeName = node.TypeOperand.FullName,
Operand = operand
});
return node;
}
protected override Expression VisitInvocation(InvocationExpression node)
{
VisitAndConvert(node.Expression);
var objectNode = _nodeStack.Pop();
var arguments = new List<AcExpressionNode>();
foreach (var arg in node.Arguments)
{
VisitAndConvert(arg);
arguments.Add(_nodeStack.Pop());
}
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.Invoke,
TypeName = node.Type.FullName,
Object = objectNode,
Arguments = arguments
});
return node;
}
#region Helper Methods
private MemberBindingNode ConvertMemberBinding(MemberBinding binding)
{
return binding switch
{
MemberAssignment assignment => ConvertMemberAssignment(assignment),
MemberMemberBinding memberBinding => new MemberBindingNode
{
MemberName = memberBinding.Member.Name,
BindingType = MemberBindingType.MemberBinding,
Bindings = memberBinding.Bindings.Select(ConvertMemberBinding).ToList()
},
MemberListBinding listBinding => new MemberBindingNode
{
MemberName = listBinding.Member.Name,
BindingType = MemberBindingType.ListBinding,
Initializers = listBinding.Initializers
.Select(i => i.Arguments.Select(ConvertArgument).ToList())
.ToList()
},
_ => throw new NotSupportedException($"Member binding type '{binding.BindingType}' is not supported.")
};
}
private MemberBindingNode ConvertMemberAssignment(MemberAssignment assignment)
{
VisitAndConvert(assignment.Expression);
var expr = _nodeStack.Pop();
return new MemberBindingNode
{
MemberName = assignment.Member.Name,
BindingType = MemberBindingType.Assignment,
Expression = expr
};
}
private AcExpressionNode ConvertArgument(Expression expression)
{
VisitAndConvert(expression);
return _nodeStack.Pop();
}
private static bool IsClosureAccess(MemberExpression node)
{
return node.Expression switch
{
ConstantExpression => true,
MemberExpression nested => IsClosureAccess(nested),
_ => false
};
}
private static object? EvaluateClosureValue(MemberExpression node)
{
var objectStack = new Stack<MemberExpression>();
Expression? current = node;
while (current is MemberExpression me)
{
objectStack.Push(me);
current = me.Expression;
}
if (current is not ConstantExpression constant)
throw new InvalidOperationException("Expected constant at root of closure access.");
object? value = constant.Value;
while (objectStack.Count > 0)
{
var me = objectStack.Pop();
value = me.Member switch
{
FieldInfo fi => fi.GetValue(value),
PropertyInfo pi => pi.GetValue(value),
_ => throw new InvalidOperationException($"Unsupported member type: {me.Member.GetType()}")
};
}
return value;
}
private static string? SerializeValue(object? value)
{
if (value == null) return null;
// Handle IQueryable source - serialize as placeholder
if (value is IQueryable)
return null;
return JsonSerializer.Serialize(value, JsonOptions);
}
#endregion
}

View File

@ -0,0 +1,10 @@
# ExpressionHelpers
LINQ expression tree serialization and deserialization to JSON, enabling expression transport over SignalR or HTTP. Converts `Expression` trees to a recursive `AcExpressionNode` DTO and back, supporting binary, unary, lambda, member access, method calls, conditionals, new/member-init, and array expressions.
## Key Files
- **`AcExpressionNode.cs`** -- Universal DTO representing any expression node. Includes `ParameterNode` and `MemberBindingNode` supporting types. Recursively models the full expression tree and is JSON-serializable.
- **`AcExpressionHelper.cs`** -- Static facade for serializing/deserializing expressions and `IQueryable` query trees. Provides `ExpressionToJson`, `ExpressionFromJson`, `QueryToJson`, and `ApplyQueryFromJson` methods.
- **`AcExpressionSerializerVisitor.cs`** -- `ExpressionVisitor` subclass that walks an expression tree and builds the `AcExpressionNode` graph using a stack-based approach. Handles closure variable evaluation and constant serialization.
- **`AcExpressionDeserializer.cs`** -- Reconstructs `Expression` trees from `AcExpressionNode` DTOs. Resolves types, parameters, methods, and member bindings. Handles nullable type mismatches in binary expressions.

View File

@ -1,6 +0,0 @@
namespace AyCode.Blazor.Components.Services;
public interface IAcSessionItem<TSessionItemId> where TSessionItemId : notnull
{
public TSessionItemId SessionId { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace AyCode.Blazor.Components.Services.Logins;
public class AcWebAuthService
{
public virtual void Logout()
{}
}

View File

@ -0,0 +1,7 @@
# Logins
Web authentication service for the Blazor application.
## Key Files
- **`AcWebAuthService.cs`** -- `AcWebAuthService` class with a virtual `Logout()` method. Intended as a base class for application-specific auth services to override with concrete logout logic.

View File

@ -0,0 +1,12 @@
# Services
Application services including authentication, grid data source prototyping, and LINQ expression tree serialization for SignalR/HTTP transport.
## Key Files
- **`AcGridDataSource.cs`** -- Commented-out prototype of a `GridCustomDataSource` wrapping an `AcSignalRDataSource` with OData-style filtering. Not currently active; the working implementation lives in `Components/Grids/MgGridSignalRDataSource.cs`.
## Subfolders
- **`Logins/`** -- Web authentication service.
- **`ExpressionHelpers/`** -- LINQ expression tree serialization and deserialization to JSON for transport over SignalR or HTTP.

View File

@ -1,19 +0,0 @@
namespace AyCode.Blazor.Components.Services;
public class SignalRRequestModel
{
public DateTime RequestDateTime;
public DateTime ResponseDateTime;
public object? ResponseByRequestId = null;
public SignalRRequestModel()
{
RequestDateTime = DateTime.UtcNow;
}
public SignalRRequestModel(object responseByRequestId) : this()
{
ResponseByRequestId = responseByRequestId;
}
}

View File

@ -1,46 +0,0 @@
using System.Reflection;
using AyCode.Core.Extensions;
namespace AyCode.Blazor.Components.Services;
public static class TrackingItemHelpers
{
public static T JsonClone<T>(T source) => source.ToJson().JsonTo<T>()!;
public static T ReflectionClone<T>(T source)
{
var type = source!.GetType();
if (type.IsPrimitive || typeof(string) == type)
return source;
if (type.IsArray)
{
var elementType = Type.GetType(type.FullName!.Replace("[]", string.Empty))!;
var array = (source as Array)!;
var cloned = Array.CreateInstance(elementType, array.Length);
for (var i = 0; i < array.Length; i++)
cloned.SetValue(ReflectionClone(array.GetValue(i)), i);
return (T)Convert.ChangeType(cloned, type);
}
var clone = Activator.CreateInstance(type);
while (type != null && type != typeof(object))
{
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
var fieldValue = field.GetValue(source);
if (fieldValue == null) continue;
field.SetValue(clone, ReflectionClone(fieldValue));
}
type = type.BaseType;
}
return (T)clone!;
}
}

View File

@ -0,0 +1,18 @@
# MgGrid — Columns
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
## MgGridDataColumn
Extended `DxGridDataColumn` with InfoPanel and URL link support.
| Parameter | Type | Default | Description |
|---|---|---|---|
| `ShowInInfoPanel` | `bool` | `true` | Whether this column is visible in InfoPanel |
| `InfoPanelDisplayFormat` | `string?` | `null` | Custom display format for InfoPanel |
| `InfoPanelOrder` | `int` | `int.MaxValue` | Column order in InfoPanel (lower = earlier) |
| `UrlLink` | `string?` | `null` | URL template with `{Property}` placeholders |
**UrlLink example:** `https://admin.example.com/Entity/Edit/{Id}` — renders cell as `<a href="..." target="_blank">`.
Uses compiled property accessors (`ConcurrentDictionary` cache) for performance.

View File

@ -0,0 +1,101 @@
# MgGrid — Lifecycle & CRUD
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
## Lifecycle
```
1. OnInitializedAsync()
├── Validate Logger, SignalRClient (throw if null)
├── Create SignalRCrudTags from message tag parameters
├── Create TSignalRDataSource via Activator.CreateInstance(SignalRClient, crudTags, ContextIds)
├── Set DataSource.FilterText
├── Bind grid Data to data source inner list
└── Subscribe to: OnDataSourceLoaded, OnDataSourceItemChanged, OnSyncingStateChanged
2. SetParametersAsyncCore() [first time]
├── Set KeyFieldName = "Id"
├── Wire 6 DxGrid events → internal handlers (see MGGRID_PARAMETERS.md)
├── Add OnCustomizeElement handler (edit row highlighting, detail cell styling)
└── Set defaults: TextWrapEnabled=false, AllowSelectRowByClick=true, etc.
3. OnParametersSet() [first time]
├── Set GridName default: "{TDataItem.Name}Grid"
├── Set AutoSaveLayoutName default: "Grid{TDataItem.Name}"
├── Wire layout auto-loading/saving handlers
└── Register with GridWrapper via GridWrapper.RegisterGrid(this)
4. OnAfterRenderAsync(firstRender: true)
├── If DataSource parameter was provided: LoadDataSource(dataSourceParam, sync, notify)
└── Else: LoadDataSourceAsync(notify) — fires SignalR GetAll request
```
## CRUD Operations
### Adding Items
```csharp
await grid.AddDataItem(item); // local add, sync later
await grid.AddDataItemAsync(item); // immediate server sync
await grid.InsertDataItem(0, item); // insert at index, sync later
await grid.InsertDataItemAsync(0, item); // insert at index, immediate sync
```
### Other CRUD Methods
| Method | Description |
|---|---|
| `UpdateDataItem(item)` | Local update, sync later |
| `UpdateDataItemAsync(item)` | Immediate server sync |
| `AddOrUpdateDataItem(item)` | Add if new, update if existing |
| `RemoveDataItem(item)` | Remove by entity reference |
| `RemoveDataItem(id)` | Remove by ID |
| `ReloadDataSourceAsync()` | Re-fetch all data from server |
| `ForceRenderAsync()` | Force grid re-initialization via new render key |
### ID Generation for New Items
New items get **temporary client-side IDs** until the server assigns real ones:
| TId Type | Strategy | Example |
|---|---|---|
| `Guid` | `Guid.NewGuid()` | `a1b2c3d4-...` |
| `int` | `-1 * AcDomain.NextUniqueInt32` | `-1`, `-2`, `-3`, ... |
**Convention:** Negative integer IDs indicate unsaved items. The server replaces them with real auto-increment IDs.
### Edit Flow (Inline)
```
User clicks Edit → OnEditStart → OnCustomizeEditModel
├── Set GridEditState = New/Edit
├── For new items: assign temp ID, set parent FK if detail grid
├── Notify InfoPanel: SetEditMode()
└── Fire OnGridCustomizeEditModel callback
User clicks Save → OnItemSaving
├── Fire OnGridEditModelSaving callback (can cancel)
├── If new: AddDataItemAsync / InsertDataItemAsync
├── If existing: UpdateDataItemAsync
├── Reset GridEditState = None
└── Clear InfoPanel edit mode
User clicks Cancel → OnEditCanceling
├── Reset GridEditState = None
└── Clear InfoPanel edit mode
```
### Edit Row Highlighting
When `GridEditState != None`, the focused row and its cells get `background-color: #fffbeb` (warm yellow) via `OnCustomizeElement`.
## Disposal
`DisposeAsync()` handles cleanup:
1. Set `_isDisposed = true` (guards all async callbacks)
2. Unsubscribe from `OnDataSourceLoaded`, `OnDataSourceItemChanged`, `OnSyncingStateChanged`
3. Remove `OnCustomizeElement` handler
4. `GC.SuppressFinalize(this)`
All async callbacks check `_isDisposed` before proceeding.

View File

@ -0,0 +1,13 @@
# MgGrid — DataSource
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
> For the underlying `AcSignalRDataSource`: `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` (in AyCode.Core repo)
## MgGridSignalRDataSource
`GridCustomDataSource` wrapper around `AcSignalRDataSource` for server-side data operations.
- Returns local data instantly for previously-seen filter criteria
- Refreshes from the server in the background
- Handles filter, sort, paging, unique values, and summary calculations locally
- `OnBackgroundRefreshCompleted` event fires when background refresh completes

View File

@ -0,0 +1,25 @@
# MgGrid — Master-Detail
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
## How It Works
1. `MgGridBase.BuildRenderTree` wraps content in `CascadingValue<IMgGridBase>`
2. Child grids receive this via `[CascadingParameter] IMgGridBase? ParentGrid`
3. `IsMasterGrid` = `ParentDataItem == null`
4. `GetRootGrid()` walks the `ParentGrid` chain to find the topmost grid
## Detail Grid Setup
```razor
<DetailRowTemplate>
@{
var parent = (ParentEntity)context.DataItem;
<GridChildEntity ParentDataItem="@parent"
KeyFieldNameToParentId="ParentEntityId"
ContextIds="@(new object[] { parent.Id })" />
}
</DetailRowTemplate>
```
When `ParentDataItem` is set and `KeyFieldNameToParentId` is provided, new items automatically get their parent FK set via reflection.

View File

@ -0,0 +1,98 @@
# MgGrid — InfoPanel
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
## MgGridWithInfoPanel Wrapper
```razor
<MgGridWithInfoPanel ShowInfoPanel="true" InfoPanelSize="400px">
<GridContent>
<GridMyEntityBase @ref="Grid" ... />
</GridContent>
<ChildContent>
@* Optional: custom InfoPanel — if omitted, default MgGridInfoPanel is used *@
</ChildContent>
</MgGridWithInfoPanel>
```
| Parameter | Type | Default | Description |
|---|---|---|---|
| `GridContent` | `RenderFragment` | — | The grid to display in the left pane |
| `ChildContent` | `RenderFragment?` | `null` | Custom InfoPanel. If `null`, renders `MgGridInfoPanel` |
| `ShowInfoPanel` | `bool` | `true` | Whether to show the right pane |
| `InfoPanelSize` | `string` | `"400px"` | Initial right pane size |
The wrapper provides:
- `DxSplitter` with collapsible right pane
- Fullscreen overlay (`mg-fullscreen-overlay`)
- Splitter size persistence (`Splitter_{key}` in localStorage)
- `RegisterGrid(grid)` — called by MgGridBase in `OnParametersSet`
- `RegisterInfoPanel(infoPanel)` — called by MgGridInfoPanel in `OnAfterRenderAsync`
## MgGridInfoPanel
Default InfoPanel component implementing `IInfoPanelBase`. Displays focused-row details with edit support.
### IInfoPanelBase Interface
```csharp
public interface IInfoPanelBase
{
void RefreshData(IMgGridBase grid, object? dataItem, int visibleIndex = -1);
void SetEditMode(IMgGridBase grid, object editModel);
void ClearEditMode();
}
```
### InfoPanel Data Flow
```
FocusedRowChanged → InfoPanelInstance.RefreshData(grid, dataItem, visibleIndex)
Edit starts → InfoPanelInstance.SetEditMode(grid, editModel)
Edit ends/cancel → InfoPanelInstance.ClearEditMode()
```
`InfoPanelInstance` resolution: own `GridWrapper.InfoPanelInstance` → root grid's `GridWrapper.InfoPanelInstance``null`.
### Responsive Column Layout
| Breakpoint Parameter | Default | Columns |
|---|---|---|
| — | < 400px | 1 column |
| `TwoColumnBreakpoint` | 400px | 2 columns |
| `ThreeColumnBreakpoint` | 800px | 3 columns |
| `FourColumnBreakpoint` | 1300px | 4 columns |
`FixedColumnCount` (1-4) overrides responsive breakpoints if set.
### Template System
| Template | Context | Purpose |
|---|---|---|
| `HeaderTemplate` | `InfoPanelContext` | Custom header (default: grid Caption) |
| `BeforeColumnsTemplate` | `InfoPanelContext` | Content before column-value pairs |
| `ColumnsTemplate` | `InfoPanelContext` | Replace default column rendering entirely |
| `AfterColumnsTemplate` | `InfoPanelContext` | Content after column-value pairs |
| `FooterTemplate` | `InfoPanelContext` | Custom footer |
`InfoPanelContext` = `record(object? DataItem, bool IsEditMode)`.
### Edit Mode Editors (by property type)
| Type | Editor Component |
|---|---|
| `bool` | `DxCheckBox<bool>` |
| `DateTime` / `DateTime?` | `DxDateEdit<DateTime>` / `DxDateEdit<DateTime?>` |
| `DateOnly` / `DateOnly?` | `DxDateEdit<DateOnly>` / `DxDateEdit<DateOnly?>` |
| `int` | `DxSpinEdit<int>` |
| `decimal` | `DxSpinEdit<decimal>` |
| `double` | `DxSpinEdit<double>` |
| ComboBox (via `DxComboBoxSettings`) | `DxComboBox<TValue, TItem>` |
| Memo (via `EditSettingsType.Memo`) | `DxMemo` |
| Other | `DxTextBox` |
### Additional Features
- **Sticky positioning** via JS interop (`MgGridInfoPanel.initSticky`)
- **Built-in toolbar** with `MgGridToolbarTemplate` (`OnlyGridEditTools=true`)
- **OnDataItemChanged** callback when focused row changes

View File

@ -0,0 +1,27 @@
# MGGRID — Known Issues
For planned/actionable work see `MGGRID_TODO.md`.
No formally-tracked issues yet. In-code TODOs are recorded as TODO entries (`ACBLAZOR-GRID-T-*`) in `MGGRID_TODO.md`, since they describe unfinished work rather than confirmed broken behaviour.
Add the first `## ACBLAZOR-GRID-I-XXXX: ...` entry below when a concrete issue is observed.
## Issue entry template
```
## ACBLAZOR-GRID-I-XXXX: Short title
**Severity:** Trivial / Low / Minor / Major · **Status:** Open / Documented / Mitigated · **Area:** <subsystem>
### Description
What breaks, and under what conditions.
### Root cause
Why it happens (code location + design mismatch).
### Known workaround
Steps a consumer can take until fixed.
### Related TODO
`MGGRID_TODO.md#acblazor-grid-t-XXXX` (if applicable).
```

View File

@ -0,0 +1,46 @@
# MgGrid — Layout Persistence
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
## Storage Keys
Grid layouts are stored in **localStorage** with structured keys:
```
AutoSave: {AutoSaveLayoutName}_{MasterOrParentTypeName}_AutoSave_{UserId}
UserSave: {AutoSaveLayoutName}_{MasterOrParentTypeName}_UserSave_{UserId}
Splitter: Splitter_{grid.AutomaticLayoutStorageKey}
```
**Examples:**
```
GridOrder_Master_AutoSave_42 ← master grid, user #42
GridOrder_Order_AutoSave_42 ← detail grid under Order parent
GridOrder_Master_UserSave_42 ← manually saved layout
Splitter_GridOrder_Master_AutoSave_42 ← splitter pane size
```
## Three Layout Tiers
| Tier | Key Contains | When Saved | When Loaded |
|---|---|---|---|
| **Default** | (in-memory `_defaultLayoutJson`) | First `LayoutAutoLoading` — captures layout before any load | `ResetLayoutAsync()` — restores original |
| **AutoSave** | `_AutoSave_` | Every `LayoutAutoSaving` event (on any layout change) | Every `LayoutAutoLoading` event (on grid init, wrapped in `BeginUpdate`/`EndUpdate`) |
| **UserSave** | `_UserSave_` | `SaveUserLayoutAsync()` — explicit user action | `LoadUserLayoutAsync()` — explicit user action |
## Layout Operations
| Method | Behavior |
|---|---|
| `SaveUserLayoutAsync()` | Saves current layout to both UserSave AND AutoSave keys |
| `LoadUserLayoutAsync()` | Loads from UserSave key (if exists) |
| `ResetLayoutAsync()` | Removes AutoSave key, restores in-memory `_defaultLayoutJson` |
| `HasUserLayoutAsync()` | Checks if UserSave key exists in localStorage |
## Persisted State
The layout (`GridPersistentLayout`) includes: column order, column widths, sort descriptors, group descriptors, filter row values, page size — serialized as JSON via `System.Text.Json`.
## User Identification
`GetLayoutUserId()` is virtual — defaults to `0`. Override in project adapter to provide the logged-in user's ID.

View File

@ -0,0 +1,82 @@
# MgGrid — Parameters & Events
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
## Required Parameters
| Parameter | Type | Description |
|---|---|---|
| `SignalRClient` | `AcSignalRClientBase` | SignalR client for server communication |
| `Logger` | `TLoggerClient` | Logger instance |
| `GetAllMessageTag` | `int` | SignalR tag for loading all items |
## CRUD Message Tags
| Parameter | Type | Description |
|---|---|---|
| `GetAllMessageTag` | `int` | Tag for "get all items" request |
| `GetItemMessageTag` | `int` | Tag for "get single item" request |
| `AddMessageTag` | `int` | Tag for "add item" request |
| `UpdateMessageTag` | `int` | Tag for "update item" request |
| `RemoveMessageTag` | `int` | Tag for "remove item" request |
These are bundled into a `SignalRCrudTags` during `OnInitializedAsync`. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` (in AyCode.Core repo) for details.
## Data & Context Parameters
| Parameter | Type | Description |
|---|---|---|
| `DataSource` | `IList<TDataItem>` | Bind with `AcObservableCollection<TDataItem>` for external data. If not set, grid creates its own. |
| `ParentDataItem` | `IId<TId>?` | Parent entity for detail grids. `null` = master grid. |
| `KeyFieldNameToParentId` | `string?` | Property name on `TDataItem` that references the parent's ID |
| `ContextIds` | `object[]?` | Additional context passed to `AcSignalRDataSource` constructor |
| `FilterText` | `string?` | Text filter — propagated to data source, triggers reload |
## Display & Behavior Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Caption` | `string` | `typeof(TDataItem).Name` | Grid title (shown in fullscreen header) |
| `GridName` | `string` | `"{TDataItem.Name}Grid"` | Name used in log messages |
| `AutoSaveLayoutName` | `string?` | `"Grid{TDataItem.Name}"` | Base name for layout storage keys |
## Event Callbacks
All grid events are re-exposed with `OnGrid` prefix to avoid collisions with `DxGrid` base events:
| Event | DxGrid Equivalent | When Fired |
|---|---|---|
| `OnGridItemDeleting` | `DataItemDeleting` | Before item removal (can cancel via `e.Cancel`) |
| `OnGridEditModelSaving` | `EditModelSaving` | Before item save (can cancel via `e.Cancel`) |
| `OnGridEditStart` | `EditStart` | When edit mode begins |
| `OnGridCustomizeEditModel` | `CustomizeEditModel` | When edit model is being prepared |
| `OnGridFocusedRowChanged` | `FocusedRowChanged` | When focused row changes |
| `OnDataSourceChanged` | — | After data source is loaded/reloaded |
| `OnGridItemChanged` | — | After server confirms a CRUD operation |
| `OnGridItemChanging` | — | Before a CRUD operation is sent to server |
Internal event wiring (in `SetParametersAsyncCore`, first call):
| DxGrid Event | → Internal Handler |
|---|---|
| `DataItemDeleting` | `OnItemDeleting` |
| `EditModelSaving` | `OnItemSaving` |
| `CustomizeEditModel` | `OnCustomizeEditModel` |
| `FocusedRowChanged` | `OnFocusedRowChanged` |
| `EditStart` | `OnEditStart` |
| `EditCanceling` | `OnEditCanceling` |
## Default Grid Settings
Set in `SetParametersAsyncCore` (first call only):
| Setting | Value |
|---|---|
| `KeyFieldName` | `"Id"` |
| `TextWrapEnabled` | `false` |
| `AllowSelectRowByClick` | `true` |
| `HighlightRowOnHover` | `true` |
| `AutoCollapseDetailRow` | `true` |
| `AutoExpandAllGroupRows` | `false` |
Project adapters typically add more defaults in `OnParametersSet` (e.g., `EditMode`, `FocusedRowEnabled`, `PageSize`, `ShowFilterRow`, `SizeMode` based on `IsMasterGrid`).

View File

@ -0,0 +1,21 @@
# MgGrid — Fullscreen & Rendering
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
## Fullscreen Mode
Two modes depending on whether `MgGridWithInfoPanel` wraps the grid:
| Scenario | Behavior |
|---|---|
| **With wrapper** | `ToggleFullscreen()` delegates to wrapper — fullscreen includes grid + InfoPanel |
| **Standalone** | Grid renders its own `mg-fullscreen-overlay` with header (Caption + close button) and body |
## Rendering
`BuildRenderTree` uses manual render tree building (not Razor markup):
1. Outer `CascadingValue<IMgGridBase>` — provides this grid as `ParentGrid` to children
2. If standalone fullscreen: `div.mg-fullscreen-overlay` > `div.mg-fullscreen-header` + `div.mg-fullscreen-body` > `base.BuildRenderTree`
3. If normal: `div[style=display:contents]` > `base.BuildRenderTree`
4. `_gridRenderKey` (Guid) used as element key — changed by `ForceRenderAsync()` to force re-initialization

View File

@ -0,0 +1,81 @@
# MGGRID — TODO
For known issues / bugs see `MGGRID_ISSUES.md`.
## Priority legend
- **P0** blocker · **P1** important · **P2** nice-to-have · **P3** idea
---
## ACBLAZOR-GRID-T-V4P7: Generic ID generation in `MgGridBase.SetNewId`
**Priority:** P2 · **Type:** Refactor (framework-first) · **Origin:** 2026-04-24 in-code TODO audit · **Area:** `Components/Grids/MgGridBase.cs:460`
`SetNewId(TDataItem dataItem)` branches on `dataItem.Id is Guid` vs `dataItem.Id is int` to produce a new ID, and converts through `TypeConverter` back to `TId`. The in-code comment `//TODO: int !!! - J.` flags this as unfinished — the logic is not cleanly generic over `TId`.
```csharp
private void SetNewId(TDataItem dataItem)
{
//TODO: int !!! - J.
if (dataItem.Id is Guid)
{
dataItem.Id = (TId)(_typeConverterId.ConvertTo(Guid.NewGuid(), typeof(TId)))!;
}
else if (dataItem.Id is int)
{
var newId = -1 * AcDomain.NextUniqueInt32;
dataItem.Id = (TId)(_typeConverterId.ConvertTo(newId, typeof(TId)))!;
}
}
```
Problems:
- Runtime type switch on a generic parameter defeats the point of generics.
- Silent no-op for any other `TId` (e.g., `long`, `short`, custom struct) — no compile error, no throw.
- The negative-int convention (`-1 * AcDomain.NextUniqueInt32`) is not expressed as a contract; consumers cannot override.
### Fix options
- **(a)** Introduce `IAcNewIdGenerator<TId>` framework abstraction (in AyCode.Core) with default implementations for `Guid` and `int`. `MgGridBase` takes it via DI or generic parameter. Consumer-specific `TId` types register their generator.
- **(b)** Virtual protected method: `protected virtual TId GenerateNewId()` on `MgGridBase`, with default implementations for `Guid` and `int` preserved. Consumer overrides for custom `TId`.
- **(c)** Static strategy map keyed by `typeof(TId)` — registered once per app startup, resolved at runtime.
### Acceptance criteria
- No runtime type-switch in `MgGridBase`.
- Throws explicit `NotSupportedException` (or similar) for unregistered `TId` types.
- Existing `Guid` and `int` consumers unaffected.
- Remove the `//TODO: int !!! - J.` comment.
## ACBLAZOR-GRID-T-S2L9: Implement local grouping in `MgGridSignalRDataSource.GetGroupInfoAsync`
**Priority:** P3 · **Type:** Feature · **Origin:** 2026-04-24 in-code TODO audit · **Area:** `Components/Grids/MgGridSignalRDataSource.cs:202`
```csharp
public override async Task<IList<GridCustomDataSourceGroupInfo>> GetGroupInfoAsync(
GridCustomDataSourceGroupingOptions options,
CancellationToken cancellationToken)
{
_logger?.Debug("[MgGridSignalRDataSource] GetGroupInfoAsync");
// TODO: Implement local grouping when needed
return await base.GetGroupInfoAsync(options, cancellationToken);
}
```
Currently delegates to the DevExpress base implementation, which for a server-side `GridCustomDataSource` triggers a server round-trip. With `MgGridSignalRDataSource` already holding a local cache (see `MGGRID_DATASOURCE.md`), grouping over the cached rows would avoid that round-trip.
Not urgent — only light grouping usage so far; the base path works. Promote to P2 if a consumer hits perceptible grouping latency.
### Acceptance criteria
- Local grouping computed from the cached list when the full dataset is cached.
- Falls back to base (server) path when the cache is partial / paginated.
- Unit / integration test with a grouped column.
- Remove the `// TODO: Implement local grouping when needed` comment.
## TODO entry template
```
## ACBLAZOR-GRID-T-XXXX: Short title
**Priority:** P0 / P1 / P2 / P3 · **Type:** Bug fix / Feature / Refactor / Docs · **Related:** `MGGRID_ISSUES.md#acblazor-grid-i-XXXX` (if applicable)
Description of what and why, including the trigger (user request, audit finding, incident).
Options / sub-tasks / acceptance criteria.
```

View File

@ -0,0 +1,39 @@
# MgGrid — Toolbar
> Part of the MgGrid system. See `README.md` for overview and component hierarchy.
## MgGridToolbarTemplate
The standard toolbar rendered inside grid's `ToolbarTemplate`. Provides all standard grid operations.
### Toolbar Buttons
| Group | Buttons | Visible |
|---|---|---|
| **CRUD** | New, Edit, Delete | When NOT editing |
| **Edit mode** | Save, Cancel | When editing |
| **Navigation** | Prev Row, Next Row | When NOT editing |
| **Layout** | Column Chooser, Layout (Load/Save/Reset) | When `OnlyGridEditTools=false` |
| **Actions** | Export (CSV/XLSX/XLS/PDF), Reload Data, Fullscreen | When `OnlyGridEditTools=false` |
### Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Grid` | `IMgGridBase` | required | The grid to control |
| `OnlyGridEditTools` | `bool` | `false` | Show only CRUD + navigation (used by InfoPanel) |
| `ShowOnlyIcon` | `bool` | `false` | Hide button text, show only icons |
| `EnableNew` | `bool` | `true` | Enable "New" button |
| `EnableEdit` | `bool` | `true` | Enable "Edit" button |
| `EnableDelete` | `bool` | `false` | Enable "Delete" button |
| `ToolbarItemsExtended` | `RenderFragment?` | `null` | Extra toolbar items after standard buttons |
| `OnReloadDataClick` | `EventCallback` | — | Callback for "Reload Data" button |
### State Properties (computed from Grid)
| Property | Source |
|---|---|
| `IsEditing` | `Grid.GridEditState != MgGridEditState.None` |
| `IsSyncing` | `Grid.IsSyncing` |
| `HasFocusedRow` | `Grid.GetFocusedRowIndex() >= 0` |
| `IsFullscreenMode` | `Grid.IsFullscreen` |

View File

@ -0,0 +1,118 @@
# MgGrid System
> Comprehensive documentation for the **MgGrid** component family — the primary UI data grid pattern in the AyCode.Blazor framework.
> Source: `Components/Grids/`
> For SignalR transport: `AyCode.Services/docs/SIGNALR/README.md` (in AyCode.Core repo)
> For `AcSignalRDataSource`: `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` (in AyCode.Core repo)
## Overview
**MgGridBase** is an abstract generic Blazor component that extends DevExpress `DxGrid` with:
- **Automatic SignalR CRUD** via `AcSignalRDataSource` (see AyCode.Core docs)
- **Layout persistence** — column order, widths, sorting, grouping auto-saved to `localStorage`
- **Master-detail hierarchy** — nested grids share context via `CascadingParameter`
- **InfoPanel integration** — side panel shows focused row details, supports edit mode
- **Fullscreen mode** — standalone overlay or via `MgGridWithInfoPanel` wrapper
- **Change tracking** — client-side tracking with server sync via `SaveChangesAsync`
## Detailed Documentation
| File | Topics |
|---|---|
| `MGGRID_PARAMETERS.md` | Component parameters (required, CRUD tags, data & context, display), event callbacks, default grid settings |
| `MGGRID_CRUD.md` | Lifecycle, CRUD operations, ID generation, edit flow, disposal |
| `MGGRID_LAYOUT.md` | Layout persistence (storage keys, three tiers, operations, persisted state) |
| `MGGRID_DETAIL.md` | Master-detail hierarchy |
| `MGGRID_RENDERING.md` | Fullscreen mode, rendering |
| `MGGRID_INFOPANEL.md` | MgGridInfoPanel, MgGridWithInfoPanel wrapper, responsive layout, templates, editors |
| `MGGRID_TOOLBAR.md` | MgGridToolbarTemplate (buttons, parameters, state) |
| `MGGRID_COLUMNS.md` | MgGridDataColumn (InfoPanel params, UrlLink) |
| `MGGRID_DATASOURCE.md` | MgGridSignalRDataSource (server-side data, local cache, background refresh) |
| `MGGRID_ISSUES.md` | Known issues (`ACBLAZOR-GRID-I-*`, `ACBLAZOR-GRID-B-*`) — currently none formally tracked |
| `MGGRID_TODO.md` | Planned work (`ACBLAZOR-GRID-T-*`) — refactors, missing features, optimizations |
## Component Hierarchy
```
DxGrid (DevExpress)
└── MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient> (AyCode.Blazor — abstract)
└── [Project-specific adapter] ← consumer fixes TSignalRDataSource, TId, TLoggerClient
└── [Concrete entity grid] ← consumer sets CRUD tags in constructor
```
### Companion Components
| Component | Purpose | Docs |
|---|---|---|
| **MgGridWithInfoPanel** | `DxSplitter` wrapper: grid + InfoPanel, fullscreen, splitter persistence | `MGGRID_INFOPANEL.md` |
| **MgGridToolbarBase** | `DxToolbar` base with `Grid` reference, `RefreshClick` callback | `MGGRID_TOOLBAR.md` |
| **MgGridToolbarTemplate** | Full toolbar: CRUD, navigation, layout menu, fullscreen, export | `MGGRID_TOOLBAR.md` |
| **MgGridDataColumn** | Extended `DxGridDataColumn` with InfoPanel params and `UrlLink` | `MGGRID_COLUMNS.md` |
| **MgGridInfoPanel** | Default InfoPanel: column-value pairs, edit mode, typed editors | `MGGRID_INFOPANEL.md` |
| **MgGridSignalRDataSource** | `GridCustomDataSource` wrapper: server-side filter/sort/page, local cache | `MGGRID_DATASOURCE.md` |
## Generic Type Parameters
```csharp
MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>
```
| Parameter | Constraint | Purpose |
|---|---|---|
| `TSignalRDataSource` | `: AcSignalRDataSource<…>` | SignalR-backed data source (see `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` in AyCode.Core repo) |
| `TDataItem` | `: class, IId<TId>` | Entity type displayed in the grid |
| `TId` | `: struct` | Primary key type (`int`, `Guid`) |
| `TLoggerClient` | `: AcLoggerBase` | Logger for diagnostics |
### Usage Example (Project-Specific Adapter)
```csharp
// Project adapter — fixes TSignalRDataSource, TId, TLoggerClient for the entire project
public class MyProjectGridBase<TDataItem>
: MgGridBase<MySignalRDataSource<TDataItem>, TDataItem, int, MyLoggerClient>
where TDataItem : class, IId<int>
{
[Inject] public required MyLoggedInModel LoggedInModel { get; set; }
protected override int GetLayoutUserId() => LoggedInModel.UserId;
}
// Concrete grid — only TDataItem remains open
public class GridOrderBase : MyProjectGridBase<Order>
{
public GridOrderBase()
{
GetAllMessageTag = MySignalRTags.GetOrders;
AddMessageTag = MySignalRTags.AddOrder;
UpdateMessageTag = MySignalRTags.UpdateOrder;
}
}
```
## Interface: IMgGridBase
The public contract exposed to companion components (toolbar, InfoPanel, wrapper):
| Member | Type | Description |
|---|---|---|
| `IsSyncing` | `bool` | Whether SignalR sync is in progress |
| `Caption` | `string` | Grid title |
| `GridEditState` | `MgGridEditState` | `None` / `New` / `Edit` |
| `ParentGrid` | `IMgGridBase?` | Parent in master-detail hierarchy |
| `GetRootGrid()` | `IMgGridBase` | Walks to topmost grid |
| `StepPrevRow()` | `void` | Navigate to previous visible row |
| `StepNextRow()` | `void` | Navigate to next visible row |
| `InfoPanelInstance` | `IInfoPanelBase?` | Resolved InfoPanel reference |
| `IsFullscreen` | `bool` | Current fullscreen state |
| `AutomaticLayoutStorageKey` | `string` | Current auto-save storage key |
| `ToggleFullscreen()` | `void` | Toggle fullscreen mode |
| `SaveUserLayoutAsync()` | `Task` | Save layout manually |
| `LoadUserLayoutAsync()` | `Task` | Load manually saved layout |
| `ResetLayoutAsync()` | `Task` | Reset to default layout |
| `HasUserLayoutAsync()` | `Task<bool>` | Check if manual save exists |
## Event Args Classes
| Class | Base | Extra Properties |
|---|---|---|
| `GridDataItemChangedEventArgs<T>` | — | `Grid`, `DataItem`, `TrackingState`, `CancelStateChangeInvoke` |
| `GridDataItemChangingEventArgs<T>` | `GridDataItemChangedEventArgs<T>` | `IsCanceled` |

View File

@ -0,0 +1,16 @@
# AyCode.Blazor.Components documentation
Topic documentation for the `AyCode.Blazor.Components` project (Blazor component library).
## Topics
- [`MGGRID/`](MGGRID/README.md) — MGGRID data grid component family (CRUD, layout, columns, toolbar, rendering, etc.)
## Navigation
Per the AI Agent Core Protocol (folder navigation rule), start from this README when browsing `docs/`. Each topic folder has its own `README.md` with the main content, plus `TOPIC_*.md` sub-topic files.
## See also
- **Blazor repo conventions**: `../../docs/CONVENTIONS.md`
- **Architecture overview**: `../../docs/ARCHITECTURE.md`

View File

@ -0,0 +1,324 @@
/* MgGridInfoPanel styles - DevExpress Fluent theme compatible */
/*
DevExpress Fluent theme uses --DS-* CSS variables (Design System tokens).
These are defined on .dxbl-theme-fluent class and inherited by child elements.
*/
/* Main panel - uses DevExpress Design System variables */
.mg-grid-info-panel {
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
color: var(--DS-color-content-neutral-default-rest, #212529);
font-family: var(--DS-font-family-sans-serif, inherit);
font-size: var(--DS-font-size-body-1, 14px);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
max-height: 100%;
border: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
}
.mg-grid-info-panel.edit-mode {
background-color: var(--DS-color-surface-warning-subdued-rest, #fffbeb);
border: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
border-left: 3px solid var(--DS-color-border-warning-default-rest, #f59e0b);
}
/* Container queries support - progressive enhancement */
@supports (container-type: inline-size) {
.mg-grid-info-panel {
container-type: inline-size;
container-name: infopanel;
}
}
/* Header styling */
.mg-grid-info-panel .mg-info-panel-header {
padding: var(--DS-sizing-80, 0.5rem) var(--DS-sizing-160, 1rem);
background-color: var(--DS-color-surface-neutral-default-rest, #ffffff);
border-bottom: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
font-weight: var(--DS-font-weight-subtitle-2, 600);
}
/* Toolbar styling */
.mg-info-panel-toolbar {
padding: var(--DS-sizing-40, 0.25rem) var(--DS-sizing-80, 0.5rem);
background-color: var(--DS-color-surface-neutral-default-rest, #ffffff);
border-bottom: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
}
/* Content area - scrollable */
.mg-info-panel-content {
flex: 1 1 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--DS-sizing-160, 1rem);
min-height: 0;
}
/* Grid layout for columns */
.mg-info-panel-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--DS-sizing-80, 0.5rem);
}
/* Fixed column count classes */
.mg-columns-1 .mg-info-panel-grid {
grid-template-columns: 1fr !important;
}
.mg-columns-2 .mg-info-panel-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.mg-columns-3 .mg-info-panel-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
.mg-columns-4 .mg-info-panel-grid {
grid-template-columns: repeat(4, 1fr) !important;
}
/* Responsive layouts using container queries */
@container infopanel (min-width: 400px) {
.mg-grid-info-panel:not(.mg-columns-1):not(.mg-columns-2):not(.mg-columns-3):not(.mg-columns-4) .mg-info-panel-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@container infopanel (min-width: 800px) {
.mg-grid-info-panel:not(.mg-columns-1):not(.mg-columns-2):not(.mg-columns-3):not(.mg-columns-4) .mg-info-panel-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@container infopanel (min-width: 1300px) {
.mg-grid-info-panel:not(.mg-columns-1):not(.mg-columns-2):not(.mg-columns-3):not(.mg-columns-4) .mg-info-panel-grid {
grid-template-columns: repeat(4, 1fr);
}
}
/* Grid item */
.mg-info-panel-item {
min-width: 0;
}
/* Label styling */
.mg-info-panel-label {
display: block;
margin-bottom: var(--DS-sizing-40, 0.25rem);
font-size: var(--DS-font-size-caption-1, 12px);
font-weight: var(--DS-font-weight-caption-1-strong, 600);
color: var(--DS-color-content-neutral-subdued-rest, #6c757d);
}
.mg-info-panel-label.editable {
color: var(--DS-color-content-primary-default-rest, #0d6efd);
}
/* View mode value styling */
.mg-info-panel-value {
display: block;
padding: var(--DS-sizing-40, 0.25rem) 0;
color: var(--DS-color-content-neutral-default-rest, #212529);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mg-info-panel-value-numeric {
font-variant-numeric: tabular-nums;
}
.mg-info-panel-value-date {
font-variant-numeric: tabular-nums;
}
/* Empty state */
.mg-info-panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--DS-color-content-neutral-subdued-disabled, #adb5bd);
padding: var(--DS-sizing-240, 1.5rem);
text-align: center;
}
/* ========================================
Tables inside info panel - Default Base Styling
======================================== */
.mg-info-panel-content table {
width: 100%;
max-width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: var(--DS-font-size-body-1, 14px);
color: var(--DS-color-content-neutral-default-rest, #212529);
margin-bottom: var(--DS-sizing-160, 1rem);
}
.mg-info-panel-content table th,
.mg-info-panel-content table td {
padding: var(--DS-sizing-40, 0.25rem) var(--DS-sizing-80, 0.5rem);
border: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.mg-info-panel-content table th {
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
font-weight: var(--DS-font-weight-body-1-strong, 600);
color: var(--DS-color-content-neutral-subdued-rest, #6c757d);
}
.mg-info-panel-content table tbody tr:nth-child(odd) {
background-color: var(--DS-color-surface-neutral-default-rest, #ffffff);
}
.mg-info-panel-content table tbody tr:nth-child(even) {
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
}
.mg-info-panel-content table tbody tr:hover {
background-color: var(--DS-color-surface-neutral-default-hovered, #f5f5f5);
}
/* Responsive: make table more compact on smaller screens */
@media (max-width: 768px) {
.mg-info-panel-content table {
font-size: 0.875rem;
}
.mg-info-panel-content table th,
.mg-info-panel-content table td {
padding: 0.375rem;
}
}
/* Splitter pane styling - no padding/margin */
.mg-grid-with-info-panel {
height: 100%;
/* Override DevExpress splitter pane padding variables */
--dxbl-splitter-pane-padding-x: 0;
--dxbl-splitter-pane-padding-y: 0;
--dxbl-splitter-pane-padding-x-s: 0;
--dxbl-splitter-pane-padding-x-m: 0;
--dxbl-splitter-pane-padding-x-l: 0;
--dxbl-splitter-pane-padding-y-s: 0;
--dxbl-splitter-pane-padding-y-m: 0;
--dxbl-splitter-pane-padding-y-l: 0;
}
.mg-grid-with-info-panel > .dxbl-splitter-pane {
padding: 0 !important;
margin: 0;
}
.mg-info-panel-pane {
background-color: var(--DS-color-surface-neutral-subdued-rest, #f8f9fa);
padding: 0 !important;
margin: 0;
}
/* Fullscreen overlay styling (Bootstrap 5 based) */
.mg-fullscreen-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
z-index: 1050;
background-color: var(--DS-color-surface-neutral-default-rest, #ffffff);
display: flex;
flex-direction: column;
overflow: hidden;
}
.mg-fullscreen-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background-color: var(--DS-color-surface-primary-default-rest, #0d6efd);
color: var(--DS-color-content-neutral-static-inverted-rest, #fff);
border-bottom: 1px solid var(--DS-color-border-neutral-default-rest, #dee2e6);
flex-shrink: 0;
}
.mg-fullscreen-title {
font-size: var(--DS-font-size-subtitle-2, 1.1rem);
font-weight: var(--DS-font-weight-subtitle-2, 600);
margin: 0;
}
.mg-fullscreen-header .btn-close-white {
filter: brightness(0) invert(1);
opacity: 0.8;
}
.mg-fullscreen-header .btn-close-white:hover {
opacity: 1;
}
.mg-fullscreen-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mg-fullscreen-body .mg-grid-with-info-panel,
.mg-fullscreen-body .dxbl-grid {
flex: 1;
height: 100%;
}
/* Legacy DxWindow styling (kept for backwards compatibility) */
.mg-fullscreen-window {
position: fixed !important;
top: 0 !important;
left: 0 !important;
margin: 0 !important;
border-radius: 0 !important;
}
.mg-fullscreen-window .dxbl-window-body {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mg-fullscreen-content {
flex: 1;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mg-fullscreen-content .mg-grid-with-info-panel,
.mg-fullscreen-content .dxbl-grid {
flex: 1;
height: 100%;
}
/* Fullscreen icon classes */
.grid-fullscreen::before {
content: "\e90c";
font-family: 'devextreme-icons';
}
.grid-fullscreen-exit::before {
content: "\e90d";
font-family: 'devextreme-icons';
}

View File

@ -1,6 +0,0 @@
// This is a JavaScript module that is loaded on demand. It can export any number of
// functions, and may import other JavaScript modules if required.
export function showPrompt(message) {
return prompt(message, 'Type anything here');
}

View File

@ -0,0 +1,18 @@
// MgCardView - Scroll handling
window.MgCardView = {
scrollToElement: function (elementId) {
const element = document.getElementById(elementId);
if (!element) return;
// Find the closest scroll container
const scrollArea = element.closest('.mg-card-scroll-area');
if (scrollArea) {
const containerRect = scrollArea.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const offset = elementRect.top - containerRect.top - (containerRect.height - elementRect.height) / 2;
scrollArea.scrollBy({ top: offset, behavior: 'smooth' });
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
};

View File

@ -0,0 +1,120 @@
// MgGridInfoPanel - Sticky scroll handling
// Makes the InfoPanel sticky to viewport when scrolling
window.MgGridInfoPanel = {
observers: new Map(),
// Initialize sticky behavior for an InfoPanel element
initSticky: function (element, topOffset) {
if (!element) return;
const elementId = element.id || this.generateId(element);
// Clean up existing observer if any
this.disposeSticky(element);
// Store the initial position of the element (relative to document)
const rect = element.getBoundingClientRect();
const initialTop = rect.top + window.scrollY;
// Calculate and set initial state
this.updatePosition(element, initialTop);
// Handler to update position on scroll and resize
const updateHandler = () => {
this.updatePosition(element, initialTop);
};
// Add event listeners - use passive to not block scrolling
window.addEventListener('resize', updateHandler, { passive: true });
window.addEventListener('scroll', updateHandler, { passive: true });
// Store cleanup info
this.observers.set(elementId, {
element: element,
updateHandler: updateHandler,
initialTop: initialTop
});
return true;
},
// Dispose sticky behavior
disposeSticky: function (element) {
if (!element) return;
const elementId = element.id || this.findElementId(element);
const observer = this.observers.get(elementId);
if (observer) {
window.removeEventListener('resize', observer.updateHandler);
window.removeEventListener('scroll', observer.updateHandler);
// Reset styles
element.style.height = '';
element.style.maxHeight = '';
element.style.transform = '';
this.observers.delete(elementId);
}
},
// Update panel position and height based on scroll
updatePosition: function (element, initialTop) {
if (!element) return;
const scrollY = window.scrollY;
const viewportHeight = window.innerHeight;
const bottomPadding = 30; // 30px from bottom
// Calculate how much we've scrolled past the initial position
const scrolledPast = Math.max(0, scrollY - initialTop);
// Get the splitter pane to know our container limits
const paneContainer = element.closest('.dxbl-splitter-pane');
let maxScrollOffset = Infinity;
if (paneContainer) {
// Don't scroll past the bottom of the pane
const paneHeight = paneContainer.offsetHeight;
const elementHeight = element.offsetHeight;
maxScrollOffset = Math.max(0, paneHeight - elementHeight);
}
// Clamp the scroll offset
const translateY = Math.min(scrolledPast, maxScrollOffset);
// Apply transform to make it "sticky"
element.style.transform = `translateY(${translateY}px)`;
// Calculate height: from current visual position to viewport bottom
const rect = element.getBoundingClientRect();
const visualTop = rect.top; // This already accounts for transform
// Height from current visual top to viewport bottom minus padding
const availableHeight = viewportHeight - visualTop - bottomPadding;
// Clamp height
const finalHeight = Math.max(200, Math.min(availableHeight, viewportHeight - bottomPadding));
element.style.height = finalHeight + 'px';
element.style.maxHeight = finalHeight + 'px';
},
// Generate a unique ID for the element
generateId: function (element) {
const id = 'mg-info-panel-' + Math.random().toString(36).substr(2, 9);
element.id = id;
return id;
},
// Find element ID from stored observers
findElementId: function (element) {
for (const [id, observer] of this.observers.entries()) {
if (observer.element === element) {
return id;
}
}
return null;
}
};

View File

@ -0,0 +1,64 @@
// Lazy content observer - renders content only when visible in viewport
window.lazyContentObserver = {
_observers: new Map(),
observe: function (element, dotNetRef, rootMargin, threshold) {
if (!element) {
console.error('[MgLazyLoadContent] Element not found');
return false;
}
// Clean up existing observer for this element
this.unobserve(element);
const options = {
root: null, // viewport
rootMargin: rootMargin || '50px',
threshold: threshold || 0.01
};
// Check immediate visibility before setting up observer
const rect = element.getBoundingClientRect();
const isCurrentlyVisible = (
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0
);
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Invoke .NET callback
dotNetRef.invokeMethodAsync('OnVisibilityChanged', entry.isIntersecting)
.catch(err => console.error('[MgLazyLoadContent] Callback error:', err));
});
}, options);
observer.observe(element);
this._observers.set(element, { observer, dotNetRef });
console.log('[MgLazyLoadContent] Observer initialized. Currently visible:', isCurrentlyVisible);
// Return immediate visibility status
return isCurrentlyVisible;
},
unobserve: function (element) {
if (!element) return;
const data = this._observers.get(element);
if (data) {
data.observer.disconnect();
this._observers.delete(element);
console.log('[MgLazyLoadContent] Observer removed');
}
},
dispose: function () {
this._observers.forEach((data, element) => {
data.observer.disconnect();
});
this._observers.clear();
console.log('[MgLazyLoadContent] All observers disposed');
}
};

View File

@ -1,64 +1,64 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<RunAOTCompilation>true</RunAOTCompilation>
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj" />
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
</ItemGroup>
<Import Project="..//AyCode.Blazor.targets" />
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Core.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Database">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Database.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj" />
<ProjectReference Include="..\AyCode.Blazor.Models\AyCode.Blazor.Models.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Loggers\" />
</ItemGroup>
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Core.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Database">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Database.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="2.5.187" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
<PackageReference Include="Serialize.Linq" Version="3.1.160" />
</ItemGroup>
<ItemGroup>
<Folder Include="Loggers\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,27 @@
# AyCode.Blazor.Controllers
@project {
type = "framework"
own-dep-projects = [
"AyCode.Core, AyCode.Core.Server, AyCode.Database, AyCode.Entities, AyCode.Entities.Server, AyCode.Interfaces, AyCode.Interfaces.Server, AyCode.Models, AyCode.Models.Server, AyCode.Services, AyCode.Services.Server, AyCode.Utils (in AyCode.Core repo)"
]
}
Scaffolding project for Blazor controllers and logging infrastructure. Currently contains only a placeholder class with a reserved folder for future logger implementations.
## Key Files
- **`Class1.cs`** -- Empty placeholder class in the `AyCode.Blazor.Controllers` namespace.
## Reserved Folders
- **`Loggers/`** -- Future logger implementations.
## Dependencies
| Dependency | Version | Type |
|---|---|---|
| AyCode.Blazor.Models | -- | ProjectReference |
| AyCode.Blazor.Models.Server | -- | ProjectReference |
| MessagePack | 3.1.4 | NuGet |
| Microsoft.AspNetCore.SignalR.Client | 9.0.11 | NuGet |
| Microsoft.AspNetCore.SignalR.Common | 9.0.11 | NuGet |
| AyCode.Core, .Core.Server, .Database, .Entities, .Entities.Server, .Interfaces, .Interfaces.Server, .Models, .Models.Server, .Services, .Services.Server, .Utils | -- | DLL references |

View File

@ -1,42 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<RunAOTCompilation>true</RunAOTCompilation>
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
</PropertyGroup>
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Core.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Database">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Database.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<Import Project="..//AyCode.Blazor.targets" />
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Core.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Database">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Database.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Folder Include="Models\" />
<Folder Include="Services\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,33 @@
# AyCode.Blazor.Models.Server
@project {
type = "framework"
own-dep-projects = [
"AyCode.Core, AyCode.Core.Server, AyCode.Database, AyCode.Entities, AyCode.Entities.Server, AyCode.Interfaces, AyCode.Interfaces.Server, AyCode.Models.Server, AyCode.Services, AyCode.Services.Server, AyCode.Utils (in AyCode.Core repo)"
]
}
Scaffolding project for server-side Blazor models and services. Currently contains only a placeholder class with reserved folders for future implementation.
## Key Files
- **`Class1.cs`** -- Empty placeholder class in the `AyCode.Blazor.Models.Server` namespace.
## Reserved Folders
- **`Models/`** -- Future server-side model definitions.
- **`Services/`** -- Future server-side service implementations.
## Dependencies
| Dependency | Type |
|---|---|
| AyCode.Core | DLL reference |
| AyCode.Core.Server | DLL reference |
| AyCode.Database | DLL reference |
| AyCode.Entities | DLL reference |
| AyCode.Entities.Server | DLL reference |
| AyCode.Interfaces | DLL reference |
| AyCode.Interfaces.Server | DLL reference |
| AyCode.Models.Server | DLL reference |
| AyCode.Services | DLL reference |
| AyCode.Services.Server | DLL reference |
| AyCode.Utils | DLL reference |

View File

@ -1,45 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<RunAOTCompilation>true</RunAOTCompilation>
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
</PropertyGroup>
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Core.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<Import Project="..//AyCode.Blazor.targets" />
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Core.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services.Server">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.Server.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@ -0,0 +1,33 @@
# AyCode.Blazor.Models
@project {
type = "framework"
own-dep-projects = [
"AyCode.Core, AyCode.Core.Server, AyCode.Entities, AyCode.Entities.Server, AyCode.Interfaces, AyCode.Interfaces.Server, AyCode.Models, AyCode.Models.Server, AyCode.Services, AyCode.Services.Server, AyCode.Utils (in AyCode.Core repo)"
]
}
.NET 10 class library with AOT compilation (`RunAOTCompilation` + `WasmStripILAfterAOT`) providing view model base classes and shared models for the AyCode Blazor UI layer.
## Key Files
- **`Class1.cs`** -- Empty placeholder class in the root namespace.
- **`ViewModels/`** -- Abstract view model base classes (see [ViewModels/README.md](ViewModels/README.md)).
## Dependencies
All referenced as pre-built DLLs from `AyCode.Core\AyCode.Services.Server\bin\`:
| Assembly | Type |
|---|---|
| AyCode.Core | DLL reference |
| AyCode.Core.Server | DLL reference |
| AyCode.Entities | DLL reference |
| AyCode.Entities.Server | DLL reference |
| AyCode.Interfaces | DLL reference |
| AyCode.Interfaces.Server | DLL reference |
| AyCode.Models | DLL reference |
| AyCode.Models.Server | DLL reference |
| AyCode.Services | DLL reference |
| AyCode.Services.Server | DLL reference |
| AyCode.Utils | DLL reference |

View File

@ -0,0 +1,22 @@
# ViewModels
Abstract view model base classes for the AyCode Blazor UI. All classes are abstract and currently contain no members, serving as the foundation for concrete view models in downstream projects.
## Key Files
- **`AcViewModelBase.cs`** -- Root abstract base class for all view models.
- **`AcDomainViewModel.cs`** -- Abstract base for domain-level view models (standalone, does not extend `AcViewModelBase`).
- **`AcGridViewModelBase.cs`** -- Abstract base for grid/table view models; extends `AcViewModelBase`.
- **`AcPageViewModelBase.cs`** -- Abstract base for page-level view models; extends `AcViewModelBase`.
- **`AcSiteViewModel.cs`** -- Abstract base for site-wide view models; extends `AcDomainViewModel`.
## Inheritance
```
AcViewModelBase
+-- AcGridViewModelBase
+-- AcPageViewModelBase
AcDomainViewModel
+-- AcSiteViewModel
```

9
AyCode.Blazor.targets Normal file
View File

@ -0,0 +1,9 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="AyCode.Project.targets" />
<PropertyGroup>
<Name>AyCode.Blazor.targets</Name>
<TargetFramework>$(_TargetFramework)</TargetFramework>
</PropertyGroup>
</Project>

View File

@ -1,47 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<Import Project="..//AyCode.Maui.targets" />
<PropertyGroup>
<TargetFrameworks>net8.0;net8.0-maccatalyst;net8.0-ios;net8.0-android34.0</TargetFrameworks>
<TargetFrameworks>net10.0-android;net10.0-ios</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.26100.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">14.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">29.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
<RunAOTCompilation>true</RunAOTCompilation>
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">33.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.19041.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.19041.0</TargetPlatformMinVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="2.5.187" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />
</ItemGroup>
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Core.dll</HintPath>
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Core.dll</HintPath>
</Reference>
<Reference Include="AyCode.Entities">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Entities.dll</HintPath>
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Entities.dll</HintPath>
</Reference>
<Reference Include="AyCode.Interfaces">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Interfaces.dll</HintPath>
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Interfaces.dll</HintPath>
</Reference>
<Reference Include="AyCode.Models">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Models.dll</HintPath>
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Models.dll</HintPath>
</Reference>
<Reference Include="AyCode.Services">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Services.dll</HintPath>
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Services.dll</HintPath>
</Reference>
<Reference Include="AyCode.Utils">
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\Debug\net8.0\AyCode.Utils.dll</HintPath>
<HintPath>..\..\AyCode.Core\AyCode.Services.Server\bin\$(_AyCodeCoresReferenceBuildSubPath)\AyCode.Utils.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.11" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,13 @@
# Platforms
Platform-specific code for AyCode.Maui.Core. Each subfolder is conditionally compiled only for its target platform. All classes currently contain empty `PlatformClass1` placeholders.
## Subfolders
- **`Android/`** -- Android-specific code (API 33+).
- **`iOS/`** -- iOS-specific code (15.0+).
- **`MacCatalyst/`** -- Mac Catalyst-specific code (15.0+).
- **`Windows/`** -- Windows-specific code (10.0.19041+).
- **`Tizen/`** -- Tizen-specific code (currently commented out in the .csproj).
## Key Files
- **`{Platform}/PlatformClass1.cs`** -- Empty placeholder class per platform, all in the `AyCode.Maui.Core` namespace.

View File

@ -0,0 +1,25 @@
# AyCode.Maui.Core
@project {
type = "framework"
own-dep-projects = [
"AyCode.Core, AyCode.Entities, AyCode.Interfaces, AyCode.Models, AyCode.Services, AyCode.Utils (in AyCode.Core repo)"
]
}
.NET MAUI class library targeting Android (API 33+), iOS (15.0+), and Windows (10.0.19041+). Provides cross-platform shared code with per-platform specialization via the `Platforms/` folder.
## Key Files
- **`Class1.cs`** -- Shared placeholder class included on all platforms.
- **`Platforms/`** -- Platform-specific code (see [Platforms/README.md](Platforms/README.md)).
## Dependencies
| Dependency | Version | Type |
|---|---|---|
| Microsoft.Maui.Controls | 10.0.11 | NuGet |
| Microsoft.AspNetCore.Components.WebView.Maui | 10.0.11 | NuGet |
| Microsoft.AspNetCore.SignalR.Client | 9.0.11 | NuGet |
| Microsoft.AspNetCore.SignalR.Common | 9.0.11 | NuGet |
| MessagePack | 3.1.4 | NuGet |
| AyCode.Core, .Entities, .Interfaces, .Models, .Services, .Utils | -- | DLL references |

7
AyCode.Maui.targets Normal file
View File

@ -0,0 +1,7 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="AyCode.Project.targets" />
<PropertyGroup>
<Name>AyCode.Maui.targets</Name>
</PropertyGroup>
</Project>

24
AyCode.Project.targets Normal file
View File

@ -0,0 +1,24 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Name>AyCode.Project.targets</Name>
<_ProjectName>FruitBank</_ProjectName>
<_TargetFramework>net10.0</_TargetFramework>
<_TargetFrameworkAyCodeCore>net9.0</_TargetFrameworkAyCodeCore>
<!--<GitBranch>$([System.IO.File]::ReadAlltext('$(MsBuildThisFileDirectory)\.git\HEAD').Replace('ref: refs/heads/', '').Trim())</GitBranch>
<_ProjectName>$(GitBranch)</_ProjectName>-->
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Configurations>Debug;Release;Product</Configurations>
<BaseOutputPath>bin\$(_ProjectName)</BaseOutputPath>
<_AyCodeCoresReferenceBuildSubPath>$(_ProjectName)\$(Configuration)\$(_TargetFrameworkAyCodeCore)</_AyCodeCoresReferenceBuildSubPath>
</PropertyGroup>
<Target Name="Test" AfterTargets="AfterBuild">
<Message Importance="High" Text="AYCODE.CORE(S) PATH: $(_AyCodeCoresReferenceBuildSubPath)"/>
</Target>
</Project>

View File

@ -13,6 +13,23 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Blazor.Models", "AyC
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Blazor.Models.Server", "AyCode.Blazor.Models.Server\AyCode.Blazor.Models.Server.csproj", "{8EF7D4F3-F3AB-47E1-931F-83267D2EB308}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
AyCode.Blazor.targets = AyCode.Blazor.targets
AyCode.Maui.targets = AyCode.Maui.targets
AyCode.Project.targets = AyCode.Project.targets
.github\copilot-instructions.md = .github\copilot-instructions.md
CLAUDE.md = CLAUDE.md
README.md = README.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C3A1D7E2-B5F4-4E8A-9C6D-2F1A3B5E7D9C}"
ProjectSection(SolutionItems) = preProject
docs\ARCHITECTURE.md = docs\ARCHITECTURE.md
docs\CONVENTIONS.md = docs\CONVENTIONS.md
docs\GLOSSARY.md = docs\GLOSSARY.md
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU

17
CLAUDE.md Normal file
View File

@ -0,0 +1,17 @@
CRITICAL: Your FIRST action in every session MUST be reading `.github/copilot-instructions.md`. Execute ALL session-start instructions found there before responding to any user query. It is the single source of truth for this repo.
## SEQUENTIAL EXECUTION OVERRIDE
The AI AGENT CORE PROTOCOL in copilot-instructions.md requires STRICT SEQUENTIAL execution. This OVERRIDES your default parallelization behavior. Do NOT parallelize doc reads with code searches. The sequence is:
1. Read copilot-instructions.md → process its rules FULLY
2. Read ALL docs/ .md files listed in the protocol → wait for completion
3. Output [LOADED_DOCS: ...] prefix
4. ONLY THEN respond to the user's query or search code
## Tool mapping for AI AGENT CORE PROTOCOL
The copilot-instructions.md references Copilot tool names. Map them to Claude Code tools:
- `get_file` / `file_search``Read`, `Glob`, `Grep`
- `code_search` / `get_symbols_by_name` / `find_symbol``Grep`, `Glob`
- `replace_string_in_file` / `edit_file``Edit`
- `create_file``Write`
Follow the protocol using YOUR tools. The rules (LOADED_DOCS prefix, hard-gate, no-re-read, context recovery, explicit consent) apply equally to Claude Code.

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# AyCode.Blazor Solution
Blazor Server + MAUI Hybrid UI framework built on DevExpress 25.1.3 components. Targets **net10.0** (AyCode.Project.targets). Provides reusable grids, card views, authentication, and SignalR-powered real-time data. Depends on AyCode.Core (net9.0) for serialization, entities, and services — referenced via DLL.
## LLM Context
Domain rules and UI pitfalls live in a single file: `.github/copilot-instructions.md`
| Tool | Auto-loaded | Action needed |
|------|------------|---------------|
| GitHub Copilot | ✅ `copilot-instructions.md` | None |
| Claude Code | ✅ `CLAUDE.md` → references above | None |
| Cursor / Windsurf | ✅ `README.md` | Read `copilot-instructions.md` via @file |
Solution-level docs in `docs/`:
| Document | Topic |
|---|---|
| `GLOSSARY.md` | Blazor/MAUI terminology |
| `ARCHITECTURE.md` | Solution layers, dependency rules |
| `CONVENTIONS.md` | Coding conventions |
Project-level docs:
| Project | Documents |
|---|---|
| `AyCode.Blazor.Components/docs/` | `MGGRID/` — MgGrid system (grid base, toolbar, InfoPanel, layout, CRUD) |
Core framework rules: `../AyCode.Core/.github/copilot-instructions.md`
## Solution Structure
| Project | Purpose |
|---|---|
| `AyCode.Blazor.Components` | DevExpress UI components, grids, SignalR data sources, expression helpers |
| `AyCode.Blazor.Models` | Shared view models for Blazor components |
| `AyCode.Blazor.Models.Server` | Server-side model scaffolding |
| `AyCode.Blazor.Controllers` | Controller scaffolding (minimal) |
| `AyCode.Maui.Core` | MAUI cross-platform: Android, iOS, Windows |
### Test Projects
| Project | Purpose |
|---|---|
| `AyCode.Blazor.Components.Tests` | Grid and component tests |

75
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,75 @@
# Architecture
## Framework vs. Consumer Boundary
This is **Layer 1 — UI framework**, building on Layer 0 (AyCode.Core) and consumed by Layer 2/3 projects. Full doctrine: `../AyCode.Core/docs/ARCHITECTURE.md#framework-vs-consumer-boundary`.
### Blazor/MAUI-specific notes
- Components use **generic type parameters** for consumer types (e.g. the MgGrid generic hierarchy)
- DevExpress wrappers stay generic — no consumer-entity specialization in framework components
- MAUI platform folders (`AyCode.Maui.Core/Platforms/`) provide platform abstractions only; consumer-app manifest / splash screens / app-specific assets belong in the consumer app
- UI patterns maximize **generic base + consumer derives**
## Dependency Graph
```
AyCode.Core Solution ../AyCode.Core/AyCode.Core.sln (DLL references)
AyCode.Blazor.Models ← AyCode.Blazor.Models.Server
AyCode.Blazor.Components ← AyCode.Blazor.Controllers
AyCode.Maui.Core (MAUI Hybrid host)
```
**Rule:** UI projects reference AyCode.Core via DLL (not ProjectReference). This separates build graphs.
**Context:** When a core type is not found in this solution, browse `../AyCode.Core/` for its definition.
## How It Works
### Blazor Server
1. **AyCode.Blazor.Components** provides all UI components (grids, card views, forms)
2. Components use **AcSignalRDataSource** to communicate with server via SignalR
3. SignalR uses **AcBinaryHubProtocol** for high-performance binary serialization
4. Grid filters are serialized as **AcExpressionNode** trees
### MAUI Hybrid
1. **AyCode.Maui.Core** hosts Blazor components in a WebView
2. Same components, same SignalR connection — different host
3. Platform-specific code in `Platforms/` folders (Android, iOS, Windows)
## Data Flow
```
User → DxGrid → AcSignalRDataSource → SignalR (AcBinary) → Server Hub → DAL → Database
User ← DxGrid ← AcSignalRDataSource ← SignalR (AcBinary) ← Server Hub
```
## MgGrid Component System
The primary UI pattern for data screens. Overview and index: `AyCode.Blazor.Components/docs/MGGRID/README.md`
```
DxGrid (DevExpress)
└── MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient>
└── [Project adapter, e.g. FruitBankGridBase<TDataItem>]
└── [Concrete grid, e.g. GridShippingBase]
```
| Component | Role |
|---|---|
| **MgGridBase** | Abstract base — SignalR CRUD, layout persistence, master-detail, edit state |
| **MgGridWithInfoPanel** | `DxSplitter` wrapper — grid + collapsible InfoPanel + fullscreen |
| **MgGridToolbarTemplate** | Full toolbar: CRUD, navigation, layout, export, fullscreen |
| **MgGridDataColumn** | `DxGridDataColumn` with InfoPanel parameters and URL template support |
| **MgGridInfoPanel** | Default InfoPanel — column-value display with edit mode |
| **MgGridSignalRDataSource** | `GridCustomDataSource` with local cache and background refresh |
## Key Design Decisions
- **DevExpress 25.1.3** exclusively — no mixing with other component libraries
- **SignalR over REST** for grid data — enables real-time updates and binary protocol
- **Expression serialization** — grid filters evaluated server-side, not client-side
- **Shared components** — Blazor Server and MAUI Hybrid use the exact same component library

48
docs/CONVENTIONS.md Normal file
View File

@ -0,0 +1,48 @@
# Conventions
For core framework conventions (Ac prefix, Session/Transaction pattern, etc.) see `AyCode.Core/docs/CONVENTIONS.md`.
## Framework-First Placement
Follow the doctrine in `../AyCode.Core/docs/CONVENTIONS.md#framework-first-placement`. Same verdicts and hard rules apply.
**Blazor/MAUI-specific additions:**
- UI types follow the generic-base pattern — consumer types are type parameters, not hardcoded concrete types
- DevExpress-wrapper components stay generic across consumers
- MAUI platform code = platform abstractions only, not consumer-app specifics
## Naming
- **Grid components:** `Grid{Entity}Base` (e.g., `GridPartnerBase`, `GridShippingBase`). Suffix `Base` because consuming projects may extend them.
- **CardView components:** Inside `Components/CardViews/` — card-style wrappers around DxGrid for mobile-friendly layouts.
- **Services:** `Ac{Domain}ServiceBase` for abstract bases. Platform-specific implementations in consuming projects.
- **ViewModels:** In `AyCode.Blazor.Models/ViewModels/` — suffixed with `ViewModel` or kept as plain model classes.
## XML Documentation
`<summary>` — brief, developer-facing, readable in VS IntelliSense tooltip. NO implementation details, NO wire-format / byte-level / perf specifics — those live in `docs/TOPIC/*.md`. Add `<example>` only when usage is non-obvious; otherwise omit.
## Component Patterns
- **DevExpress 25.1.3 exclusively** — never mix with other Blazor component libraries.
- **DxGrid + AcSignalRDataSource** — grids always bind to SignalR-backed data sources, not REST.
- **AcExpressionNode** for grid filters — LINQ expressions are serialized client-side and evaluated server-side.
- **Layout persistence** — grid column order/width saved to localStorage via `MgGridBase`.
- **ExpressionHelper** services handle AcLinq ↔ DevExpress filter conversion.
## MAUI Patterns
- **Platform folders** (`Platforms/Android/`, `iOS/`, `Windows/`) for platform-specific code.
- **BlazorWebView** hosts the same Blazor components used in Server mode.
- **IFormFactor** interface — each platform provides its own implementation (Phone, Tablet, Desktop, Web, WebAssembly).
## Blazor Patterns
- **Razor component + code-behind**`.razor` for markup, `.razor.cs` for logic.
- **Cascading parameters** for authentication state and layout context.
- **JS interop** via scoped modules (`ExampleJsInterop.cs` pattern).
## DLL References
- All AyCode.Core project references are via **DLL** (not ProjectReference) — this is intentional to separate build graphs.
- DevExpress references are NuGet packages pinned to **25.1.3**.

53
docs/GLOSSARY.md Normal file
View File

@ -0,0 +1,53 @@
# Glossary
Blazor/MAUI UI terminology. For core framework terms see `AyCode.Core/docs/GLOSSARY.md`.
## UI Components
| Term | Definition |
|---|---|
| **DxGrid** | DevExpress Blazor data grid. Used with `AcSignalRDataSource` for real-time SignalR data. |
| **CardView** | Card-style layout wrapping DxGrid. Mobile-friendly alternative to table grids. |
| **AcSignalRDataSource** | Grid data source backed by SignalR. Handles load, CRUD, filtering, and change tracking. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` in AyCode.Core repo. |
| **AcExpressionNode** | Serializable LINQ expression tree. Grid filters are serialized as expression nodes and sent to server. |
## MgGrid System
For full technical reference see `AyCode.Blazor.Components/docs/MGGRID/README.md`.
| Term | Definition |
|---|---|
| **MgGridBase** | Abstract generic grid component extending `DxGrid` with SignalR CRUD, layout persistence, master-detail, InfoPanel, fullscreen. |
| **MgGridWithInfoPanel** | `DxSplitter` wrapper: grid (left pane) + InfoPanel (right pane), fullscreen overlay, splitter size persistence. |
| **MgGridToolbarBase** | `DxToolbar` base with `Grid` (`IMgGridBase`) reference and `RefreshClick` callback. |
| **MgGridToolbarTemplate** | Full toolbar: New/Edit/Delete/Save/Cancel, row navigation, layout menu, export, fullscreen. Extensible via `ToolbarItemsExtended`. |
| **MgGridDataColumn** | Extended `DxGridDataColumn` with InfoPanel parameters and `UrlLink` template (`{Property}` placeholders). |
| **MgGridInfoPanel** | Default InfoPanel: column-value pairs for focused row, responsive columns, edit mode with typed editors, template system. |
| **MgGridSignalRDataSource** | `GridCustomDataSource` wrapping `AcSignalRDataSource`. Local cache, background refresh. |
| **IMgGridBase** | Public interface: `IsSyncing`, `GridEditState`, `ParentGrid`, `StepPrevRow/NextRow`, layout persistence methods. |
| **MgGridEditState** | Enum: `None` (no edit), `New` (adding item), `Edit` (modifying item). |
| **SignalRCrudTags** | Bundle of 5 integer message tags (GetAll, GetItem, Add, Update, Remove) for one entity type. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE/README.md` in AyCode.Core repo. |
| **IsMasterGrid** | `true` when `ParentDataItem == null` — top-level grid (not detail). |
| **AutoSaveLayoutName** | Base name for localStorage layout keys. Default: `"Grid{TDataItem.Name}"`. |
## Architecture
| Term | Definition |
|---|---|
| **Blazor Hybrid** | MAUI app hosting Blazor components via WebView. Shares UI code between web and native. |
| **DLL Reference** | AyCode.Core projects are referenced as DLLs (not ProjectReference). This is intentional — separates solution build graphs. |
| **AcBinaryHubProtocol** | Custom SignalR hub protocol using AcBinary serializer instead of default JSON. Used for performance. |
## Authentication
| Term | Definition |
|---|---|
| **AcBlazorLoginServiceBase** | Client-side login service managing JWT tokens, SecureStorage, and auto-refresh. |
| **AcAuthenticationStateProvider** | Custom Blazor auth state provider backed by JWT claims. |
## MAUI Platforms
| Term | Definition |
|---|---|
| **Platforms/** | Per-platform code folders: Android, iOS, Windows. MAUI SDK auto-includes based on target. |
| **API 33+** | Minimum Android API level (Android 13). |

23
docs/README.md Normal file
View File

@ -0,0 +1,23 @@
# AyCode.Blazor documentation
Top-level documentation for the `AyCode.Blazor` repo (Blazor/MAUI UI framework — Layer 1).
## Reference docs (flat)
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — Repo architecture overview
- [`CONVENTIONS.md`](CONVENTIONS.md) — Coding conventions
- [`GLOSSARY.md`](GLOSSARY.md) — Domain glossary
## Topic folders
Topic-specific documentation lives at the sub-project level:
- **MGGRID** (data grid component family): `../AyCode.Blazor.Components/docs/MGGRID/README.md`
## Navigation
Per the AI Agent Core Protocol (folder navigation rule), start from this README when browsing `docs/`. Single-file reference docs remain flat at this level; multi-file topics live in named subfolders.
## See also
- **Base framework docs**: `../../AyCode.Core/docs/README.md` (if present) and per-project `docs/` folders under AyCode.Core.