diff --git a/CSharpDB.slnx b/CSharpDB.slnx index 83d8c108..6882d38d 100644 --- a/CSharpDB.slnx +++ b/CSharpDB.slnx @@ -21,6 +21,7 @@ + @@ -53,6 +54,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..8d072b1f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + + $(DefaultItemExcludes);artifacts/**;**/artifacts/**;.tmp/**;**/.tmp/**;tmp/**;**/tmp/**;temp/**;**/temp/**;publish/**;**/publish/** + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..cacfd2c6 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + diff --git a/README.md b/README.md index c92e7732..c3e157f3 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,8 @@ The native library exports 20 C functions. See the [Native Library Reference](ht | [Architecture Guide](https://csharpdb.com/architecture.html) | Engine design deep dive | | [Tools & Ecosystem](https://csharpdb.com/docs/ecosystem.html) | APIs, hosts, designers, and integrations | | [EF Core Provider](https://csharpdb.com/docs/entity-framework-core.html) | Embedded EF Core 10 provider guide | +| [Trusted C# Callbacks](docs/trusted-csharp-functions/README.md) | Register in-process C# functions, commands, and validation rules for SQL, forms, reports, and pipelines | +| [Trusted C# Host Sample](samples/trusted-csharp-host/README.md) | VS Code-ready C# host project for trusted functions, commands, validation rules, and form actions | | [Admin UI Guide](https://csharpdb.com/docs/admin-ui.html) | Querying, schema, pipelines, forms, reports, and storage | | [CSharpDB.Client](src/CSharpDB.Client/README.md) | Unified client API and transports | | [Pipelines](https://csharpdb.com/docs/pipelines.html) | ETL package model and visual designer | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index dd327245..4a614773 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,320 @@ # What's New +## v3.6.0 + +v3.6.0 adds trusted, in-process C# scalar functions and commands across +CSharpDB's user-facing expression and automation surfaces. Host applications +can now register C# callbacks when opening or hosting a database, then call +those callbacks from SQL, SQL-backed triggers and procedures, Admin Forms +formulas/events/actions, Admin Reports calculated text and preview lifecycle +events, and pipeline filter/derive/hook expressions. + +The release also adds tableless scalar `SELECT` support, common built-in scalar +functions, Admin callback catalog metadata, SQL autocomplete for built-ins and +tableless-safe host callbacks, and local Admin artifact cleanup to keep +incremental builds fast. + +### Trusted C# Scalar Functions + +- Added the shared `DbFunctionRegistry`, `DbFunctionRegistryBuilder`, + `DbScalarFunctionDelegate`, and `DbScalarFunctionOptions` public model in + `CSharpDB.Primitives`. +- Added `DatabaseOptions.Functions` plus `ConfigureFunctions(...)` so embedded + hosts can register scalar functions when opening file-backed, in-memory, or + hybrid databases. +- SQL expression evaluation now resolves registered scalar functions in + projections, filters, ordering expressions, `INSERT`/`UPDATE` expressions, + trigger bodies, and stored SQL procedure bodies. +- Direct clients can pass trusted functions through `DirectDatabaseOptions`; + HTTP and gRPC clients still do not serialize delegates and can only call + functions registered inside the remote host process. +- Admin Forms formulas and Admin Reports calculated expressions can use the + same registry while preserving existing arithmetic and aggregate behavior. +- Pipeline filter and derived-column expressions can call registered functions; + package definitions store expressions plus generated automation metadata, but + never C# function bodies. +- Scalar callback registration now carries `CanRunWithoutFrom` metadata so + hosts can identify functions that are safe to discover in tableless + `SELECT ...` contexts. +- Added the usage guide at `docs/trusted-csharp-functions/README.md`. + +### Tableless SELECT And Built-In Scalar Functions + +- SQL now supports scalar `SELECT` statements without a `FROM` clause through a + single-row planner source. +- Tableless statements such as `SELECT Date();`, `SELECT abs(1123.34);`, and + `SELECT Slugify('Hello World');` can execute without inventing a dummy table + when the expression does not need row context. +- Added a central built-in scalar dispatcher for common text, date/time, + numeric, conversion, and null helpers, including functions such as `ABS`, + `DATE`, `DATESERIAL`, `DATEADD`, `DATEDIFF`, `LEN`, `UCASE`, `LCASE`, + `ROUND`, `IFNULL`, and `NZ`. +- Query planning now infers built-in scalar return types where possible. +- Query paging and Admin result serialization now handle the internal + tableless single-row source. +- BLOB procedure parameters can now round-trip through tableless + `SELECT @payload;` rather than failing on the old unsupported-path + assumption. + +### Admin Callback Catalog And Formula UX + +- The Admin navigation now groups callbacks under `Callbacks / Internal` and + `Callbacks / External`. +- Internal callbacks show built-in formula functions separately from registered + host callbacks, so the list remains navigable as the built-in surface grows. +- External callbacks show host-registered/user-created callbacks such as sample + functions and automation commands. +- Callback details now surface whether a scalar callback is marked for + tableless `SELECT`. +- SQL editor completion now suggests built-in scalar functions and host + callbacks marked with `CanRunWithoutFrom`. +- Admin Forms formulas now have an Access-style function catalog/helper and + domain-function support for common form expressions. + +### Trusted Commands And Form Events + +- Added the shared `DbCommandRegistry`, `DbCommandRegistryBuilder`, + `DbCommandDelegate`, `DbCommandContext`, `DbCommandResult`, and + `DbCommandOptions` public model in `CSharpDB.Primitives`. +- `DbCommandOptions` now includes `Timeout` and `IsLongRunning`, and + `DbCommandRegistryBuilder.AddAsyncCommand(...)` registers `Task`-based host + callbacks without manual `ValueTask` wrapping. +- Command timeouts cancel the command invocation token and surface as command + failures through the existing Forms, Reports, and Pipelines dispatch paths; + external cancellation is still propagated as cancellation. +- Admin Forms can now store form-level event bindings that reference trusted + command names instead of storing C# source. +- The Forms data-entry runtime dispatches `OnOpen`, `OnLoad`, `BeforeInsert`, + `AfterInsert`, `BeforeUpdate`, `AfterUpdate`, `BeforeDelete`, and + `AfterDelete`. +- `BeforeInsert`, `BeforeUpdate`, and `BeforeDelete` can cancel the requested + write by returning `DbCommandResult.Failure(...)`; after-events report errors + without attempting to roll back a completed write. +- Command context arguments include current record fields converted to + `DbValue`; metadata includes the Forms surface, form id/name, table name, and + event name. +- `AddCSharpDbAdminForms(...)` now has a command-registration overload for + trusted host applications. +- The Admin Forms designer preserves and edits form-level event bindings + instead of dropping automation metadata during save. +- Added a command button control that invokes a trusted host command on click, + passing current record fields, optional configured arguments, and form + metadata to the command callback. +- Added control-level Admin Forms event bindings for `OnClick`, `OnChange`, + `OnGotFocus`, and `OnLostFocus`, so ordinary controls can invoke trusted + host commands without being command buttons. +- The Forms property inspector now edits selected-control event bindings using + the same registered-command picker and JSON argument editor as form-level + events. +- Added shared declarative action sequence metadata with `RunCommand`, + `SetFieldValue`, `ShowMessage`, and `Stop` steps for Admin Forms automation. + Form and control event bindings can now be command-only, + action-sequence-only, or a command followed by an action sequence. +- Added built-in rendered-form actions for `NewRecord`, `SaveRecord`, + `DeleteRecord`, `RefreshRecords`, `PreviousRecord`, `NextRecord`, and + `GoToRecord`, so command buttons and control events can drive common form + workflows without host C# callbacks. +- Action sequence steps can now include a simple condition such as + `Status = 'Ready'`, `Amount > 0`, or `IsActive`; false conditions skip that + step, while malformed conditions fail through the normal step failure path. +- Forms can now store reusable named action sequences on `FormDefinition` and + invoke them from event/button sequences with `RunActionSequence`, including + optional per-call arguments and a nesting guard for recursive loops. +- The form-event and selected-control event editors now include a visual + action-sequence editor for adding, ordering, removing, and configuring + command, reusable sequence, field, message, stop, built-in record actions, + and per-step conditions. +- The Forms property inspector now includes a reusable action-sequence library + editor at the form level, and event action editors can pick those named + sequences while preserving missing names for portable metadata. +- The action-sequence editor uses registered-command pickers when commands are + available, preserves missing command names for portable form metadata, and + keeps JSON editing limited to optional argument payloads. +- Action sequences store names, arguments, field targets, and literal values + only. They do not store C# source, serialize delegates, or run untrusted code. +- Added shared command argument conversion helpers so Forms, Reports, and + Pipelines pass host command arguments with the same `DbValue` conversion + rules. +- Admin Reports can now bind `OnOpen`, `BeforeRender`, and `AfterRender` + preview lifecycle events to trusted commands. The preview service passes + report/source metadata plus row, truncation, page, and schema-drift metrics. +- `AddCSharpDbAdminReports(...)` now has a command-registration overload for + trusted host applications. +- Pipeline packages can now include trusted command hooks for `OnRunStarted`, + `OnBatchCompleted`, `OnRunSucceeded`, and `OnRunFailed`. Package JSON stores + hook names, arguments, and generated automation metadata only; command bodies + remain host-registered code. +- Pipeline hook failures fail the run through `PipelineRunResult`; failure-hook + errors are appended to the failed run summary instead of recursively + dispatching more failure hooks. +- Admin Forms command buttons now refresh their executing/disabled state before + and after async command work, so long-running trusted commands give visible + runtime feedback in the form surface. + +### Stored Automation Metadata + +- Added shared `DbAutomationMetadata`, command references, and scalar-function + references so portable definitions can declare the trusted host callbacks + they expect without storing C# code. +- Admin Forms, Admin Reports, and pipeline packages now regenerate automation + metadata during repository save/load or package serialization/deserialization. + Older JSON without automation metadata is backfilled on read. +- Form metadata captures trusted form events, command buttons, selected-control + events, reusable action sequences, action-sequence `RunCommand` steps, and + computed-formula scalar functions. +- Report metadata captures preview lifecycle command bindings and calculated + text scalar functions. +- Pipeline package metadata captures command hooks and scalar functions used by + filter and derived-column expressions; package validation reports stale + automation manifests so packages can be re-exported. + +### Developer Experience + +- Added `samples/trusted-csharp-host`, a VS Code-ready C# host project for + writing and debugging trusted C# callbacks in ordinary application code. +- The sample registers a trusted scalar function, calls it from SQL, registers + a trusted command, and runs an Admin Forms action sequence that sets a field + before invoking that command. +- The sample includes local `.vscode` launch/tasks files so developers can open + the sample folder, press `F5`, and set breakpoints inside callback code. +- The direct Admin launcher now cleans stale Admin artifact snapshots, builds + once, and runs with `--no-build` so old generated artifacts do not slow + startup. +- Added repo-level MSBuild cleanup for `src/CSharpDB.Admin/artifacts` and + default excludes for generated artifact folders. +- Added the async I/O batching follow-up note at + `docs/query-and-durable-write-performance/async-io-batching-follow-up.md`. + +### Behavior And Safety + +- Function names are case-insensitive SQL identifiers, and registration rejects + duplicate user names or collisions with reserved built-ins such as `TEXT`, + `COUNT`, `SUM`, `AVG`, `MIN`, and `MAX`. +- Arity is validated before invocation, missing SQL functions fail with the + existing unknown scalar function path, and thrown delegate exceptions are + wrapped with the function name before normal statement/transaction rollback. +- `NullPropagating = true` returns `NULL` without invoking the delegate when + any argument is `NULL`; otherwise `DbValue.Null` is passed explicitly. +- V1 remains scalar-only, synchronous, trusted, and in-process. It does not + persist C# source, sandbox code, load database-owned plugin assemblies, + marshal delegates over HTTP/gRPC, or add aggregate/table-valued/procedure + UDFs. +- Query planning keeps custom functions on the residual expression path in V1: + no index pushdown, generated columns, constant folding, or cost assumptions + are inferred from user functions. + +### Tests And Benchmarks + +- Added registry and SQL coverage for case-insensitive lookup, duplicate and + built-in collision rejection, null propagation, deterministic metadata, + missing functions, thrown functions, rollback behavior, triggers, and stored + SQL procedures. +- Added direct-client, Admin Forms, Admin Reports, pipeline validation, and + pipeline runtime tests for registered scalar functions. +- Added command-registry, form-event dispatcher, event JSON round-trip, and + Forms data-entry tests for create/update/delete event dispatch and + before-event cancellation. +- Added designer-state tests for action sequences, plus command-button and + control-event tests covering event binding preservation and registered + command invocation from rendered forms. +- Added Forms action-sequence tests for event dispatch, mutable record updates, + command button action-only clicks, and JSON round-tripping. +- Added report-event dispatcher and preview lifecycle tests, pipeline hook + serialization/validation/orchestrator tests, and shared command argument + conversion tests. +- Added automation metadata tests covering manifest extraction, JSON + round-tripping, repository persistence/backfill, pipeline package + import/export, and stale package metadata validation. +- Added async command and timeout coverage for the command registry, Admin + Forms dispatcher, Admin Reports dispatcher, and pipeline hook orchestration. +- Added Forms built-in action tests covering rendered command-button dispatch, + next/previous/go-to navigation, and create/save/refresh/delete workflows. +- Added conditional action tests for skip/run behavior, condition failure, + rendered built-in action skipping, metadata propagation, and JSON + round-tripping. +- Added parser, planner, SQL execution, direct-client, procedure, query paging, + and Admin completion tests for tableless `SELECT`, built-in scalar functions, + BLOB parameter round-tripping, and tableless-safe callback autocomplete. +- Added Admin callback catalog tests for tableless callback metadata. +- Added Admin Forms formula evaluator tests for the built-in function catalog + and Access-style formula helpers. +- Same-machine affected benchmark comparison against the pre-feature HEAD + baseline showed no material regression in the main write/query guardrails: + +| Suite | Worst current change | Best current change | +|-------|---------------------:|--------------------:| +| Insert | `+3.76%` | `-3.38%` | +| Join | `+6.65%` | `-6.93%` | +| PointLookup | `+5.15%` | `-9.25%` | +| QueryPlanCache | `+1.62%` | `-4.45%` | +| ScanProjection | `+0.20%` | `-18.12%` | +| TriggerDispatch | `+0.77%` | `-4.52%` | +| BatchEvaluation | `+10.53%` | `-10.36%` | + +The one notable row was the synthetic BatchEvaluation delegate +filter/projection case at `+10.53%`; its paired specialized path improved by +`-10.36%`, allocations were unchanged, and the affected guardrail suites were +otherwise neutral to improved. + +### Validation + +- `git status --short --branch` +- `dotnet restore CSharpDB.slnx` +- `.\scripts\Test-NoLegacyCoreReferences.ps1` + - Passed through the script's PowerShell fallback after the local packaged + `rg.exe` could not be launched normally in this desktop environment. +- `dotnet build CSharpDB.slnx -c Release --no-restore` + - Passed with `0` warnings and `0` errors. +- `dotnet test CSharpDB.slnx -c Release --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Non-parallel unit test run passed with `1,663` tests. +- Phase 5 local validation used `dotnet build CSharpDB.slnx --no-restore -m:1` + and `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,703` tests after adding + automation metadata coverage. +- Phase 6A async-command hardening validation used + `dotnet build CSharpDB.slnx --no-restore -m:1` and + `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,709` tests. +- Phase 6B built-in form action validation used + `dotnet build CSharpDB.slnx --no-restore -m:1` and + `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,712` tests. +- Phase 6C conditional form action validation used + `dotnet build CSharpDB.slnx --no-restore -m:1` and + `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,715` tests. +- `dotnet pack` smoke for the release workflow packages with + `-p:Version=3.6.0` + - Produced `11` local packages: + `CSharpDB`, `CSharpDB.Client`, `CSharpDB.Data`, `CSharpDB.Engine`, + `CSharpDB.EntityFrameworkCore`, `CSharpDB.Execution`, + `CSharpDB.Pipelines`, `CSharpDB.Primitives`, `CSharpDB.Sql`, + `CSharpDB.Storage`, and `CSharpDB.Storage.Diagnostics`. +- `.\scripts\Publish-CSharpDbDaemonRelease.ps1 -Version 3.6.0 -Runtime win-x64 -OutputRoot artifacts\daemon-release-local` + - Produced `csharpdb-daemon-v3.6.0-win-x64.zip` and `SHA256SUMS.txt`. +- Latest tableless/callback stabilization validation used + `dotnet test .\CSharpDB.slnx -m:1 --no-restore -v:minimal /nr:false /p:UseSharedCompilation=false /p:TestTfmsInParallel=false -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,877` tests. + +### Review Notes + +- The highest-risk runtime changes are in expression evaluation and planner + plumbing: custom functions are intentionally kept off the index-pushdown and + batch-fast-path planning assumptions in V1. +- Remote hosts must register functions in the daemon/API host process; direct + clients can register functions locally through `DirectDatabaseOptions`, but + callback delegates are never serialized over HTTP or gRPC. +- Admin Forms and Reports use the shared registries, but their formula and + automation surfaces remain narrower than SQL or stored macro systems: + formulas stay expression-focused, command hooks invoke host-owned code by + name, and declarative action sequences store only limited action metadata + rather than executable scripts in database metadata. +- `SELECT ...` without `FROM` is represented internally as a single-row source + and is intended for scalar expressions that do not need row context. +- `CanRunWithoutFrom` is currently discovery metadata for the Admin catalog and + SQL editor autocomplete; it is not yet a hard runtime denial gate for manually + typed tableless callback calls. + ## v3.5.0 v3.5.0 focuses on the collection binary payload fast path, generated diff --git a/docs/admin-collections-ui/README.md b/docs/admin-collections-ui/README.md new file mode 100644 index 00000000..57e11a15 --- /dev/null +++ b/docs/admin-collections-ui/README.md @@ -0,0 +1,59 @@ +# Admin Collections UI + +## Summary + +The Admin app now treats document collections as first-class objects alongside +tables, views, forms, and reports. The first implementation is a browser and +JSON editor built on the existing collection client APIs: + +- list collection names +- browse documents by page +- fetch one document by exact key +- create or update a document +- delete a document + +This deliberately avoids new engine, HTTP, gRPC, or client contracts. Collection +path-index management, document-content search, collection rename, and +collection drop remain future work. + +## User Experience + +- Object Explorer has a `Collections` filter chip and group. +- The collection group menu supports `New Collection...` and `Refresh`. +- Collection item menus support `Open`, `New Document...`, and `Copy Name`. +- The command palette includes `New Collection` plus one entry for each existing + collection. +- Each collection opens in a dedicated `collection:{name}` tab using the same + toolbar, pager, grid, and detail-panel language as table data tabs. + +## Collection Tab Behavior + +- The grid shows row number, document key, JSON kind, and a compact preview. +- The detail panel shows indented JSON for the selected document. +- Existing document keys are read-only. +- New documents require a nonblank key and valid JSON before save is enabled. +- Save writes through `PutDocumentAsync(collectionName, key, document)`. +- Delete writes through `DeleteDocumentAsync(collectionName, key)` after + confirmation. +- Successful writes notify Admin change listeners and refresh the current page. +- Exact-key lookup uses `GetDocumentAsync(collectionName, key)`. + +## Defaults + +- Default page size is `25`. +- Supported page sizes are `10`, `25`, `50`, and `100`. +- Collection names use the same simple identifier shape as the direct client: + `^[A-Za-z_][A-Za-z0-9_]*$`. +- Deleting all documents leaves the collection itself in place because there is + no drop-collection API today. +- Generated collection model metadata is not surfaced; documents are edited as + raw JSON. + +## Verification + +Run the focused Admin checks after collection UI changes: + +```powershell +dotnet build src/CSharpDB.Admin/CSharpDB.Admin.csproj +dotnet test tests/CSharpDB.Admin.Forms.Tests/CSharpDB.Admin.Forms.Tests.csproj +``` diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index 64070b14..f04845df 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -22,6 +22,16 @@ The current forms surface already includes: - schema-change warnings - designer undo/redo, copy/paste, duplicate, layers, alignment, tab order, and mobile/tablet/desktop breakpoint editing +- trusted host command registration for form lifecycle events +- designer editing for form-level event bindings +- command button controls that invoke trusted host commands +- trusted command-backed selected-control events +- declarative form action sequences for run-command, reusable sequence, + set-field, show-message, stop, built-in navigation, save, delete, refresh, + and go-to-record steps +- conditional action steps and reusable named form action sequences +- visual designer editing for form and selected-control action sequences +- generated automation metadata for export/import host callback requirements ## Added Review Findings @@ -96,9 +106,9 @@ Expected fix: | Feature | Status | Notes | | --- | --- | --- | -| Command button control | Planned | Add buttons that can run form actions. | -| Action model | Planned | Support actions such as open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, and show message. | -| Event hooks | Planned | Add form/control events such as on load, before save, after save, before field change, after field change, and button click. | +| Command button control | Partial | Trusted command buttons can invoke host-registered C# commands, action-only click sequences, reusable action sequences, and built-in rendered-form navigation/save/delete actions; richer button styling and command presets remain future work. | +| Action model | Partial | Declarative action sequences support run-command, reusable sequence, set-field, show-message, stop, built-in navigation/save/delete/refresh/go-to, simple per-step conditions, visual designer editing, and generated automation metadata for form and selected-control events; open form, apply filter, clear filter, run SQL/procedure, loops, and conditional UI rules remain future work. | +| Event hooks | Partial | Form lifecycle events, command-button clicks, and selected control events can call trusted commands; additional Access-style events remain future work. | | Conditional UI rules | Planned | Add visible/enabled/read-only expressions for controls. | ### Phase 5: Broader Control and Property Coverage @@ -124,3 +134,17 @@ workflows." The highest leverage model changes are: - form-mode model Those foundations should be added before expanding the control palette too far. + +## Developer Extensibility + +Custom form controls can now be registered without changing saved form JSON. See +[Form Control Extensibility](form-control-extensibility.md) for the registry API, +component contexts, generic property schema, and the sample rating control. + +Host-owned validation callbacks can be registered for field-level and form-level +save checks. See +[Trusted Validation Rules](../trusted-csharp-functions/validation-rules.md) for +registration, designer metadata, policy, diagnostics, and sample code. + +For Access-style expression functions and macro/action candidates, see +[Access-Style Functions and Macros](access-style-functions-and-macros.md). diff --git a/docs/admin-forms-access-parity/access-style-functions-and-macros.md b/docs/admin-forms-access-parity/access-style-functions-and-macros.md new file mode 100644 index 00000000..32f29b80 --- /dev/null +++ b/docs/admin-forms-access-parity/access-style-functions-and-macros.md @@ -0,0 +1,244 @@ +# Access-Style Functions and Macros + +This note captures the Access-parity function and macro set we want available +in Admin Forms. The goal is not to clone every Access/VBA surface area at once. +The goal is to include the small, familiar built-in set that makes form +expressions, validation, conditional formatting, buttons, and simple workflows +feel productive without requiring host code for every common task. + +## Design Direction + +- Keep simple expression functions built into the formula engine. +- Keep user-facing form actions as declarative Admin Form actions where possible. +- Route dangerous or host-specific actions through trusted callbacks, policy, and + diagnostics. +- Treat database-owned C# code modules as the later `RunCode` target. +- Preserve the existing saved form wire shape by storing action and expression + settings in metadata/property bags. + +Implementation note: Admin Forms formulas now include the expression functions +listed below as built-ins. Macro/action commands remain roadmap items unless +already covered by the existing form action model. + +## Included Expression Functions + +### Null and Conditional + +These should be first because they appear constantly in form defaults, +calculated controls, validation messages, and visibility/enabled rules. + +| Function | Purpose | Priority | +| --- | --- | --- | +| `Nz(value, fallback)` | Replace null/empty values with a fallback. | V1 | +| `IsNull(value)` | Test for null. | V1 | +| `IsEmpty(value)` | Test for empty/unset values where applicable. | V1 | +| `IIf(condition, trueValue, falseValue)` | Inline conditional. | V1 | +| `Switch(condition1, value1, ...)` | Multi-branch conditional. | V2 | +| `Choose(index, value1, value2, ...)` | Pick from positional values. | V2 | + +### Text + +| Function | Purpose | Priority | +| --- | --- | --- | +| `Len(value)` | Text length. | V1 | +| `Left(value, count)` | Left substring. | V1 | +| `Right(value, count)` | Right substring. | V1 | +| `Mid(value, start, count)` | Middle substring. | V1 | +| `Trim(value)` | Trim both ends. | V1 | +| `LTrim(value)` | Trim left. | V2 | +| `RTrim(value)` | Trim right. | V2 | +| `UCase(value)` | Uppercase text. | V1 | +| `LCase(value)` | Lowercase text. | V1 | +| `InStr(value, search)` | Find substring position. | V1 | +| `Replace(value, search, replacement)` | Replace text. | V1 | +| `StrComp(left, right, comparison)` | Compare strings. | V2 | +| `Val(value)` | Parse leading numeric text. | V2 | + +### Date and Time + +| Function | Purpose | Priority | +| --- | --- | --- | +| `Date()` | Current date. | V1 | +| `Time()` | Current time. | V1 | +| `Now()` | Current date/time. | V1 | +| `Year(value)` | Extract year. | V1 | +| `Month(value)` | Extract month number. | V1 | +| `Day(value)` | Extract day of month. | V1 | +| `Hour(value)` | Extract hour. | V2 | +| `Minute(value)` | Extract minute. | V2 | +| `Second(value)` | Extract second. | V2 | +| `DateAdd(interval, amount, value)` | Add date/time interval. | V1 | +| `DateDiff(interval, start, end)` | Difference between dates. | V1 | +| `DatePart(interval, value)` | Extract date/time part. | V2 | +| `DateSerial(year, month, day)` | Construct date. | V2 | +| `TimeSerial(hour, minute, second)` | Construct time. | V2 | +| `Weekday(value)` | Day of week number. | V2 | +| `MonthName(month)` | Month display name. | V2 | + +### Number and Conversion + +| Function | Purpose | Priority | +| --- | --- | --- | +| `Abs(value)` | Absolute value. | V1 | +| `Round(value, digits)` | Round number. | V1 | +| `Int(value)` | Floor-like integer conversion. | V1 | +| `Fix(value)` | Truncate toward zero. | V2 | +| `Sgn(value)` | Sign of number. | V2 | +| `CStr(value)` | Convert to string. | V1 | +| `CInt(value)` | Convert to integer. | V1 | +| `CLng(value)` | Convert to long integer. | V2 | +| `CDbl(value)` | Convert to double. | V1 | +| `CBool(value)` | Convert to boolean. | V1 | +| `CDate(value)` | Convert to date/time. | V1 | +| `Format(value, format)` | Format date/number/text. | V2 | + +### Domain Aggregates + +Domain aggregate functions are important for Access familiarity, but they need a +careful implementation because they read other rows/tables during form +evaluation. + +| Function | Purpose | Priority | +| --- | --- | --- | +| `DLookup(expr, domain, criteria)` | Read one value from a table/query. | V2 | +| `DCount(expr, domain, criteria)` | Count matching rows. | V2 | +| `DSum(expr, domain, criteria)` | Sum matching rows. | V2 | +| `DAvg(expr, domain, criteria)` | Average matching rows. | V2 | +| `DMin(expr, domain, criteria)` | Minimum matching value. | V2 | +| `DMax(expr, domain, criteria)` | Maximum matching value. | V2 | + +V2 should enforce query/table access through the same callback and diagnostics +boundary used for trusted extensions where relevant. These functions should also +have row limits and clear error handling so formula evaluation cannot become an +unbounded database workload. + +## Included Macro and Action Commands + +### Record Actions + +These map cleanly to existing form runtime behavior and should remain +declarative actions rather than host callbacks. + +| Action | Purpose | Priority | +| --- | --- | --- | +| `NewRecord` | Start a new record. | V1 | +| `SaveRecord` | Save the current record. | V1 | +| `DeleteRecord` | Delete the current record. | V1 | +| `UndoRecord` | Revert unsaved edits. | V1 | +| `RefreshRecord` | Reload the current record. | V1 | +| `Requery` | Reload the form record source. | V1 | +| `GoToRecord` | Navigate to a specific record. | V1 | +| `FindRecord` | Search/navigate by criteria. | V2 | +| `NextRecord` | Navigate forward. | V1 | +| `PreviousRecord` | Navigate backward. | V1 | + +### Form, Window, and Report Actions + +| Action | Purpose | Priority | +| --- | --- | --- | +| `OpenForm` | Open another saved form. | V1 | +| `CloseForm` | Close current or named form. | V1 | +| `OpenReport` | Open a saved report. | V2 | +| `CloseReport` | Close a report surface. | V2 | +| `PreviewReport` | Open report preview. | V2 | +| `PrintReport` | Print or export through report pipeline. | V2 | + +### Filter and Sort Actions + +| Action | Purpose | Priority | +| --- | --- | --- | +| `ApplyFilter` | Apply a form filter. | V1 | +| `ClearFilter` | Clear current filter. | V1 | +| `SetOrderBy` | Apply sort order. | V1 | +| `ClearOrderBy` | Clear sort order. | V1 | +| `SearchRecords` | Search over configured searchable fields. | V2 | + +### UI and Control Actions + +These are needed for Access-style command buttons and conditional workflows. + +| Action | Purpose | Priority | +| --- | --- | --- | +| `SetValue` | Set a field/control value. | V1 | +| `SetProperty` | Set visible/enabled/read-only/text/style properties. | V1 | +| `SetFocus` | Move focus to a control. | V1 | +| `EnableControl` | Set enabled state. | V1 | +| `DisableControl` | Clear enabled state. | V1 | +| `ShowControl` | Set visible state. | V1 | +| `HideControl` | Clear visible state. | V1 | +| `LockControl` | Set read-only state. | V1 | +| `UnlockControl` | Clear read-only state. | V1 | +| `MsgBox` | Show a message dialog. | V1 | +| `InputBox` | Prompt for a value. | V2 | + +### Flow Actions + +| Action | Purpose | Priority | +| --- | --- | --- | +| `If` / `Else` | Conditional action branching. | V1 | +| `StopMacro` | Stop the current action sequence. | V1 | +| `RunMacro` | Run a named action sequence. | V1 | +| `RunActionSequence` | Existing explicit reusable sequence action. | V1 | +| `OnError` | Configure failure handling. | V2 | + +### Data and Query Actions + +These must be gated carefully because they can mutate data outside the current +form record. + +| Action | Purpose | Priority | +| --- | --- | --- | +| `OpenQuery` | Open a saved query result. | V2 | +| `RunQuery` | Execute a saved query. | V2 trusted | +| `RunProcedure` | Execute a saved procedure. | V2 trusted | +| `RunSQL` | Execute SQL text. | Later trusted | +| `ExportData` | Export records. | Later trusted | +| `ImportData` | Import records. | Later trusted | + +### Code Bridge Actions + +| Action | Purpose | Priority | +| --- | --- | --- | +| `RunCommand` | Invoke host-registered trusted command callback. | Existing | +| `RunCode` | Invoke database-owned C# code module function. | Later | + +`RunCode` should wait for the database code modules work. It should compile and +execute database-owned C# through a trusted build/runtime model, not arbitrary +source text embedded directly inside form JSON. + +### Temp and Session Variables + +| Action | Purpose | Priority | +| --- | --- | --- | +| `SetTempVar` | Store a session-scoped value. | V2 | +| `RemoveTempVar` | Remove one session value. | V2 | +| `RemoveAllTempVars` | Clear session values. | V2 | + +Temp variables should be scoped to the current user/session and be available to +form expressions, filters, and action sequences. + +## Recommended Implementation Order + +1. Add core formula functions: `Nz`, `IIf`, `Date`, `Now`, common text helpers, + numeric helpers, and conversions. +2. Add declarative form actions: `MsgBox`, `SetValue`, `SetProperty`, + `SetFocus`, `ApplyFilter`, `ClearFilter`, `Requery`, `OpenForm`, and + `CloseForm`. +3. Add domain aggregates with clear access checks, row limits, and diagnostics. +4. Add temp/session variables and make them visible to expressions and actions. +5. Add `RunCode` after database code modules exist. +6. Add import/export/file/app-launch style actions only as trusted operations. + +## Notes on Access Compatibility + +Microsoft Access exposes many functions and macro commands. CSharpDB should use +the familiar names where it makes sense, but implementation should follow the +CSharpDB security and diagnostics model. Anything that can call host code, +modify unrelated data, access files, or run arbitrary SQL should go through an +explicit trusted boundary. + +Useful Microsoft references: + +- [Access functions by category](https://support.microsoft.com/en-us/office/access-functions-by-category-b8b136c3-2716-4d39-94a2-658ce330ed83) +- [Introduction to macros](https://support.microsoft.com/en-gb/office/introduction-to-macros-a39c2a26-e745-4957-8d06-89e0b435aac3) +- [Access macro commands](https://learn.microsoft.com/en-ie/office/client-developer/access/desktop-database-reference/macro-commands) diff --git a/docs/admin-forms-access-parity/form-control-extensibility.md b/docs/admin-forms-access-parity/form-control-extensibility.md new file mode 100644 index 00000000..178bea87 --- /dev/null +++ b/docs/admin-forms-access-parity/form-control-extensibility.md @@ -0,0 +1,154 @@ +# Form Control Extensibility + +Admin Forms controls are persisted as `ControlDefinition.ControlType` plus a +free-form `PropertyBag`. The extensibility registry turns that existing wire +shape into a developer API: a host can add designer toolbox entries, placement +defaults, property editing, designer previews, and runtime rendering without +changing saved form JSON. + +## Registration + +Register built-ins with `AddCSharpDbAdminForms()`, then add custom controls with +`AddCSharpDbAdminFormControls(...)`: + +```csharp +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; + +builder.Services.AddCSharpDbAdminForms(); +builder.Services.AddCSharpDbAdminFormControls(controls => +{ + controls.Add(new FormControlDescriptor + { + ControlType = "rating", + DisplayName = "Rating", + ToolboxGroup = "Custom", + IconText = "*", + DefaultWidth = 220, + DefaultHeight = 48, + SupportsBinding = true, + ParticipatesInTabOrder = true, + DefaultProps = new Dictionary + { + ["max"] = 5, + ["displayMode"] = "buttons", + }, + DesignerPreviewComponentType = typeof(RatingDesignerPreview), + RuntimeComponentType = typeof(RatingRuntimeControl), + PropertyEditorComponentType = typeof(RatingPropertyEditor), + }); +}); +``` + +The same registration must exist anywhere the form is designed or rendered. +Unknown control types are preserved in form metadata and render as placeholders. + +## Descriptor Fields + +`FormControlDescriptor` defines the designer and runtime contract: + +- `ControlType`: persisted control type string. +- `DisplayName`, `ToolboxGroup`, `IconText`, `Description`: toolbox and labels. +- `DefaultWidth`, `DefaultHeight`, `DefaultProps`: new-control placement. +- `SupportsBinding`: whether placement and the inspector create a field binding. +- `ParticipatesInTabOrder`: whether the control joins tab-order editing. +- `PropertyDescriptors`: generic property fields for simple custom props. +- `DesignerPreviewComponentType`: optional Blazor preview component. +- `RuntimeComponentType`: optional Blazor data-entry component. +- `PropertyEditorComponentType`: optional custom property editor. +- `ReplaceBuiltInRuntime`: lets a host explicitly replace a built-in runtime + renderer while keeping the built-in designer metadata. + +Duplicate custom control types fail when the registry is resolved. Built-in +runtime replacement also fails unless `ReplaceBuiltInRuntime = true`. + +## Component Contexts + +Custom components receive a single `Context` parameter. + +Designer previews use `FormControlDesignContext`: + +```csharp +[Parameter, EditorRequired] +public FormControlDesignContext Context { get; set; } = default!; +``` + +Runtime controls use `FormControlRuntimeContext`: + +```csharp +[Parameter, EditorRequired] +public FormControlRuntimeContext Context { get; set; } = default!; +``` + +The runtime context includes the form, control metadata, current record, bound +field/value, resolved choices, enabled/read-only state, validation error, +tab-index, `SetValueAsync`, and `DispatchEventAsync`. + +Custom property editors use `FormControlPropertyContext`: + +```csharp +[Parameter, EditorRequired] +public FormControlPropertyContext Context { get; set; } = default!; +``` + +Use `Context.SetPropertyAsync(name, value)` to write into the control +`PropertyBag`. + +## Generic Property Schema + +For simple controls, skip a custom property editor and define +`PropertyDescriptors`: + +```csharp +PropertyDescriptors = +[ + new FormControlPropertyDescriptor + { + Name = "max", + Label = "Maximum Rating", + Editor = FormControlPropertyEditor.Number, + DefaultValue = 5, + HelpText = "Allowed values are 1 through 10.", + }, + new FormControlPropertyDescriptor + { + Name = "displayMode", + Label = "Display Mode", + Editor = FormControlPropertyEditor.Select, + Options = + [ + new FormControlPropertyOption("buttons", "Buttons"), + new FormControlPropertyOption("compact", "Compact"), + ], + }, +]; +``` + +Generic editors support `Text`, `TextArea`, `Number`, `Checkbox`, and `Select`. + +## Sample Rating Control + +The Admin host includes a compiled sample custom control at: + +- `src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor` +- `src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor` +- `src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor` +- `src/CSharpDB.Admin/Components/Samples/FormControls/SampleRatingControlRegistration.cs` + +It is disabled by default. Enable it for local testing with: + +```powershell +$env:AdminForms__EnableSampleControls = 'true' +dotnet run --project src\CSharpDB.Admin --urls http://127.0.0.1:61818 +``` + +The sample registers `sampleRating` under the `Custom` toolbox group. It binds to +a scalar field, writes the selected numeric rating through `SetValueAsync`, and +dispatches click events with runtime arguments. + +## Current Limits + +V1 custom controls are leaf controls. They can be placed inside existing +`tabControl` pages, but custom containers do not own or render child controls +yet. Custom components must be compiled into the host app or referenced +assemblies; form JSON never loads arbitrary component assemblies. diff --git a/docs/admin-reports-access-parity/README.md b/docs/admin-reports-access-parity/README.md index 1195d992..f03f1956 100644 --- a/docs/admin-reports-access-parity/README.md +++ b/docs/admin-reports-access-parity/README.md @@ -23,6 +23,8 @@ The current reports surface already includes: - preview pagination with page headers and footers - print support through the browser - schema-drift warnings +- trusted command-backed preview lifecycle events for `OnOpen`, + `BeforeRender`, and `AfterRender` ## Added Review Findings @@ -120,7 +122,7 @@ Expected fix: | Feature | Status | Notes | | --- | --- | --- | -| Email/report delivery | Planned | Export and attach reports, with host-provided delivery hooks. | +| Email/report delivery | Planned | Export and attach reports; trusted `AfterRender` commands provide an initial host callback but not a full delivery pipeline. | | Scheduled reports | Research | Run recurring reports and store generated artifacts. | | Report artifact history | Research | Store generated report snapshots for auditing and re-download. | | Large-report cancellation | Planned | Add cancellation/progress for long render/export jobs. | diff --git a/docs/api-sharding.md b/docs/api-sharding.md new file mode 100644 index 00000000..11bf2a06 --- /dev/null +++ b/docs/api-sharding.md @@ -0,0 +1,190 @@ +# API-Level Sharding Plan + +This is a research and design proposal. API-level sharding is not a shipped +CSharpDB feature today. + +## Goal + +The first target for sharding is write throughput. CSharpDB's durable commit +path is isolated to a database file and its WAL, so multiple independent +database files can spread write pressure across separate commit paths while the +API decides where each request belongs. + +The recommended v1 shape is a routing layer above the existing client and +daemon surfaces, not a distributed pager or storage-engine rewrite. + +## Recommended V1 Design + +- Run one warm `ICSharpDbClient` per shard. +- Treat each shard as a normal CSharpDB database file with its own `.db` file, + WAL, checkpoints, maintenance lifecycle, and storage options. +- Add a shard catalog that maps a stable shard key such as `tenantId` or + `accountId` to a shard identity and database path or endpoint. +- Require a shard key for writes and point reads. +- Keep write transactions scoped to one shard. +- Do not support cross-shard transactions in v1. +- Add read-only fan-out later for admin, diagnostics, and reporting workloads + that explicitly need all shards. + +Conceptually: + +```text +HTTP/gRPC/API request + | + v +Shard key extraction + | + v +IShardRouter + | + +--> shard-a.db / ICSharpDbClient + +--> shard-b.db / ICSharpDbClient + +--> shard-c.db / ICSharpDbClient +``` + +## Proposed Interfaces + +These names describe the intended public shape. They are not implemented yet. + +```csharp +public interface IShardRouter +{ + ValueTask ResolveAsync( + string shardKey, + CancellationToken cancellationToken = default); +} + +public sealed record ShardRoute( + string ShardId, + string DataSource, + ICSharpDbClient Client); + +public interface IShardedCSharpDbClient +{ + ValueTask GetClientAsync( + string shardKey, + CancellationToken cancellationToken = default); +} +``` + +Two request shapes are worth considering: + +- Header-based routing: `X-CSharpDB-Shard-Key: tenant-0042` +- Route-based routing: `/api/{tenantId}/tables/...` + +Header-based routing preserves the existing URL layout. Route-based routing is +more visible and easier to test manually. Either way, the API should fail fast +when a sharded operation does not include a shard key. + +## Shard Key Guidance + +Choose the shard key before implementation. Changing it later usually requires +moving most or all data. + +Good shard keys are: + +- Stable and immutable. +- High-cardinality, such as tenant, account, organization, or workspace IDs. +- Present on the dominant write and point-read requests. +- Shared by related tables and collections so normal application workflows stay + on a single shard. + +Avoid shard keys that are: + +- Auto-incrementing IDs. +- Sequential timestamps. +- Booleans or low-cardinality enums. +- Values users commonly edit. +- Attributes that do not appear in normal request filters. + +For multitenant data, prefer colocating related tenant data in the same shard. +That means tenant-owned tables and collections should include the tenant key, +and API calls should route by that same key. + +## Query And Transaction Rules + +V1 should make single-shard behavior explicit: + +- `INSERT`, `UPDATE`, `DELETE`, document writes, point reads, and explicit + transactions require one shard key and execute on one shard. +- Raw SQL execution requires a shard key unless the endpoint is explicitly + marked as an all-shards read-only operation. +- Cross-shard joins are out of scope for v1. +- Cross-shard transactions are out of scope for v1. +- Global reads should use an explicit fan-out API that returns per-shard + results and partial-failure metadata. + +This keeps the write-throughput feature honest: each shard can commit +independently without a distributed transaction coordinator. + +## Shard Catalog + +Start with a small catalog that can be loaded at daemon/API startup: + +```json +{ + "strategy": "lookup", + "shards": [ + { "id": "shard-a", "dataSource": "data/shard-a.db" }, + { "id": "shard-b", "dataSource": "data/shard-b.db" } + ], + "routes": [ + { "key": "tenant-0001", "shardId": "shard-a" }, + { "key": "tenant-0002", "shardId": "shard-b" } + ] +} +``` + +A lookup strategy is the best first step because it supports deliberate tenant +placement and later tenant movement. A later version can introduce virtual +shards: + +```text +tenantId -> virtual shard -> physical shard +``` + +Virtual shards make rebalancing less disruptive because a new physical shard can +take ownership of some virtual shards without changing application routing code. + +## Operations + +Sharding multiplies operational work. Each operation should report per-shard +status instead of hiding failures behind one aggregate result. + +- Backup: back up every shard independently and record the shard id, database + path, timestamp, and result. +- Restore: restore one shard at a time unless an explicit all-shards restore + workflow is added. +- Reindex, vacuum, checkpoint, and inspect: support one shard or all shards with + per-shard output. +- Schema changes: apply DDL to all shards through an orchestrated migration + command and capture success/failure per shard. +- Monitoring: aggregate size, WAL, checkpoint, error, latency, and throughput + metrics by shard. +- Rebalancing: move one tenant or virtual shard at a time, block or redirect + writes during the move, validate row/document counts, update the catalog, and + refresh router caches. + +## Open Questions + +- Should the first catalog live in JSON configuration, a control CSharpDB + database, or both? +- Should API routing prefer headers, route prefixes, or support both? +- Should raw SQL require a shard key only, or should the API also validate that + the SQL filters by the shard key? +- Should fan-out reads return one combined result set, per-shard result sets, or + both? +- How should Admin UI expose shard selection and all-shards maintenance tasks? + +## Suggested Rollout + +1. Add configuration models for shard definitions and routing strategy. +2. Add an `IShardRouter` implementation backed by the configured catalog. +3. Add a sharded client wrapper that opens and owns one warm `ICSharpDbClient` + per shard. +4. Add shard-key extraction to API/daemon endpoints for writes and point reads. +5. Add one-shard maintenance operations, then all-shards maintenance with + per-shard results. +6. Add benchmarks comparing one database file versus multiple routed shards for + independent tenant write workloads. + diff --git a/docs/configuration.md b/docs/configuration.md index c0c40866..64f77182 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,7 @@ await using var db = await Database.OpenHybridAsync("mydata.db", ``` DatabaseOptions ├── ImplicitInsertExecutionMode +├── Functions ├── StorageEngineOptions │ ├── DurabilityMode │ ├── DurableGroupCommit @@ -86,6 +87,7 @@ Top-level database composition and execution-shape configuration. | Option | Type | Default | Description | |--------|------|---------|-------------| | `ImplicitInsertExecutionMode` | `ImplicitInsertExecutionMode` | `Serialized` | Controls whether shared auto-commit `INSERT` statements stay behind the legacy database write gate or run as isolated `WriteTransaction` commits. This does not disable the explicit multi-writer `WriteTransaction` APIs. | +| `Functions` | `DbFunctionRegistry` | `DbFunctionRegistry.Empty` | Trusted in-process scalar functions available to SQL and embedded expression surfaces. See [Trusted C# Scalar Functions](trusted-csharp-functions/README.md). | | `StorageEngineOptions` | `StorageEngineOptions` | default instance | Storage engine durability, pager, WAL, and checkpoint settings | | `StorageEngineFactory` | `IStorageEngineFactory` | `DefaultStorageEngineFactory` | Factory used to compose the backing storage engine | diff --git a/docs/query-and-durable-write-performance/README.md b/docs/query-and-durable-write-performance/README.md index 2f35ce79..5f14e77c 100644 --- a/docs/query-and-durable-write-performance/README.md +++ b/docs/query-and-durable-write-performance/README.md @@ -42,7 +42,7 @@ This note tracks the combined optimizer phase-2 and durable-write completion wor - The remaining phase-4 write-path question is now narrower than "shared auto-commit in general": - non-insert shared auto-commit fan-in is working - hot insert auto-commit still needs a dedicated design decision if we want it to coalesce without reopening structural conflict costs -- Async I/O batching still has room for more auditing outside the WAL hot path, but the main write-path batching pieces are already in place. +- Async I/O batching still has room for more auditing outside the WAL hot path, but the main write-path batching pieces are already in place. See [Async I/O Batching Follow-Up](async-io-batching-follow-up.md). ## Phase 4 Status diff --git a/docs/query-and-durable-write-performance/async-io-batching-follow-up.md b/docs/query-and-durable-write-performance/async-io-batching-follow-up.md new file mode 100644 index 00000000..65c104bb --- /dev/null +++ b/docs/query-and-durable-write-performance/async-io-batching-follow-up.md @@ -0,0 +1,51 @@ +# Async I/O Batching Follow-Up + +This note captures the remaining work behind the roadmap item currently marked +`In Progress`. + +## Current Shipped State + +The hot storage write path already has the main batching pieces in place: + +- WAL commits can append dirty pages through `AppendFramesAndCommitAsync(...)`. +- Repeated `AppendFrameAsync(...)` calls inside a transaction are staged and emitted as chunked WAL writes during `CommitAsync(...)`. +- Checkpoint copying batches contiguous page writes back into the main database file. +- `SaveToFileAsync(...)` and backup-style snapshot copies use `StorageDeviceCopyBatcher`. +- Vacuum and foreign-key migration rewrites share `BTreeCopyUtility` instead of each owning a separate row-copy loop. + +## Remaining Work + +The remaining work is an audit and measurement pass, not a missing core WAL batching primitive. + +1. Audit non-hot rewrite/export paths: + - Backup and restore: `DatabaseBackupCoordinator`, `Database.SaveToFileAsync(...)`, `Pager.SaveToFileAsync(...)`. + - Vacuum: `DatabaseMaintenanceCoordinator.CopyDatabaseAsync(...)`. + - Foreign-key migration rewrites: `DatabaseForeignKeyMigrationCoordinator.ApplyPlanAsync(...)`. + - Storage diagnostics and inspectors: `WalInspector`, `InspectorEngine`, and large sequential read paths. + - Admin/UI export helpers only if they prove relevant to large database-file movement. + +2. Decide whether `BTreeCopyUtility` should stay a logical row-copy helper or grow batching behavior: + - Current behavior is cursor-read plus per-row `InsertAsync(...)`. + - Possible follow-up: add row/page-local batching, reusable buffers, or progress hooks. + - Avoid direct B-tree page copying unless catalog, root-page, schema, freelist, row-count, and index invariants are explicitly preserved. + +3. Add benchmark/diagnostic coverage before optimizing further: + - Large backup snapshot. + - Large restore staging. + - Large vacuum rewrite. + - Large foreign-key migration rewrite. + - Inspector/diagnostic scans over large DB/WAL files. + +4. Define completion criteria: + - The audit identifies every large sequential file-copy and logical table-copy path. + - Each path is classified as already batched, intentionally unbatched, or worth optimizing. + - Any optimized path has before/after timings and no crash-recovery or integrity regression. + +## Non-Goals + +These are separate from this follow-up: + +- Durable group commit policy changes. +- Hot single-row insert fan-in. +- Public histogram/planner inspection. +- Query row-batch execution kernel specialization. diff --git a/docs/releases/v3.6.0-pr-notes.md b/docs/releases/v3.6.0-pr-notes.md new file mode 100644 index 00000000..c2e71fca --- /dev/null +++ b/docs/releases/v3.6.0-pr-notes.md @@ -0,0 +1,125 @@ +## Summary + +This `v3.6.0` release adds trusted in-process C# callbacks across the main +CSharpDB expression and automation surfaces, then rounds that work out with +tableless scalar `SELECT` support, built-in scalar functions, callback catalog +metadata, and Admin discovery/autocomplete polish. + +Host applications can now register trusted scalar functions and commands in +ordinary C# code. SQL, triggers, procedures, Admin Forms formulas, Admin +Reports calculated expressions, and pipeline expressions can call registered +scalar functions. Admin Forms, Admin Reports, and pipelines can also dispatch +trusted host commands from supported event and hook surfaces without storing C# +source in database metadata. + +The release also adds declarative Admin Forms action sequences, command buttons, +selected-control events, reusable named actions, Access-style formula helpers, +and generated automation metadata so portable forms/reports/packages can +declare which host callbacks they expect. + +The final stabilization pass adds `SELECT ...` without `FROM`, built-in scalar +function evaluation for common text/date/number/null helpers, a +`CanRunWithoutFrom` callback metadata flag, SQL editor completion for built-ins +and tableless-safe callbacks, an Internal/External callback catalog split, and +Admin artifact cleanup so stale generated snapshots do not slow local builds. + +## Type of Change + +- [x] Bug fix +- [x] New feature +- [ ] Breaking change +- [x] Documentation update +- [x] Refactor / maintenance +- [ ] Tests only + +## Related Issues + +No issue numbers were linked for this release branch. Included work: + +- Added trusted scalar function registration through `DbFunctionRegistry` and + `DatabaseOptions.Functions`. +- Added trusted command registration through `DbCommandRegistry`, async command + support, command timeouts, and shared command argument conversion. +- Added Admin Forms form lifecycle events, selected-control events, command + buttons, reusable action sequences, built-in record actions, and conditional + action steps. +- Added Admin Reports preview lifecycle command events. +- Added pipeline run hooks for trusted host commands. +- Added generated automation metadata for Forms, Reports, and pipeline packages. +- Added the trusted C# host sample for local callback development and debugging. +- Added tableless `SELECT` support through a single-row planner source. +- Added built-in scalar functions and SQL/Admin Forms formula coverage for + common Access-style text, date/time, numeric, conversion, and null helpers. +- Added `CanRunWithoutFrom` metadata for scalar callbacks and used it for SQL + editor discovery/autocomplete. +- Split the Admin callback catalog into Internal and External scopes. +- Added Admin artifact cleanup through MSBuild and the direct Admin launcher. +- Added async I/O batching follow-up documentation for the remaining non-hot + export/rewrite-path audit. + +## Testing + +- [x] `dotnet build CSharpDB.slnx` +- [x] Relevant tests executed +- [x] Failure-path tests executed (if applicable: cancellation, invalid/unsupported inputs, non-`DbException` paths) +- [x] Manual verification performed (if applicable) + +Validation performed: + +- `dotnet restore CSharpDB.slnx` +- `.\scripts\Test-NoLegacyCoreReferences.ps1` + - Passed through the script's PowerShell fallback after the local packaged + `rg.exe` could not be launched normally in this desktop environment. +- `dotnet build CSharpDB.slnx -c Release --no-restore` + - Passed with `0` warnings and `0` errors. +- `dotnet test CSharpDB.slnx -c Release --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Non-parallel unit test run passed with `1,663` tests. +- Multiple debug non-parallel validation passes were run during the Forms, + Reports, pipeline, command timeout, built-in action, and conditional action + phases; the final pre-tableless pass reached `1,715` tests. +- `dotnet pack` smoke for release workflow packages with `-p:Version=3.6.0` + produced `11` local packages. +- `.\scripts\Publish-CSharpDbDaemonRelease.ps1 -Version 3.6.0 -Runtime win-x64 -OutputRoot artifacts\daemon-release-local` + produced `csharpdb-daemon-v3.6.0-win-x64.zip` and `SHA256SUMS.txt`. +- Latest tableless/callback stabilization validation: + - `dotnet test .\CSharpDB.slnx -m:1 --no-restore -v:minimal /nr:false /p:UseSharedCompilation=false /p:TestTfmsInParallel=false -- RunConfiguration.DisableParallelization=true` + - Passed with `1,877` tests. + +Benchmark validation: + +- Same-machine affected benchmark comparison against the pre-feature HEAD + baseline showed no material regression in the main write/query guardrails. +- The only notable row was the synthetic `BatchEvaluation` delegate + filter/projection case at `+10.53%`; its paired specialized path improved by + `-10.36%`, allocations were unchanged, and the affected guardrail suites were + otherwise neutral to improved. + +## Checklist + +- [x] I followed the project style and conventions. +- [x] I added or updated tests for behavior changes. +- [x] I covered both success and failure paths for changed behavior. +- [x] I updated docs for user-facing changes. +- [x] I verified no sensitive data was added. + +## Notes for Reviewers + +- The highest-risk runtime changes are in expression evaluation and planner + plumbing. Custom functions are intentionally kept off index-pushdown and + batch-fast-path planning assumptions in V1. +- Remote hosts must register functions and commands in the daemon/API host + process. Direct clients can register callbacks locally, but delegates are + never serialized over HTTP or gRPC. +- `SELECT ...` without `FROM` is represented internally as a single-row source. + This is intended for scalar expressions and callbacks that do not need row + context. +- `CanRunWithoutFrom` is discovery metadata used by the Admin catalog and SQL + autocomplete. It does not yet act as a hard runtime denial gate for manually + typed tableless callback calls. +- Admin Forms and Reports use the shared registries, but their formula and + automation surfaces remain intentionally narrower than a stored macro/code + system. Form/report/pipeline metadata stores names, arguments, and action + definitions, not executable C# source. +- Built-in scalar functions now cover common helpers, but aggregate UDFs, + table-valued UDFs, native plugins, database-owned C# modules, and sandboxed + UDFs remain future work. diff --git a/docs/roadmap.md b/docs/roadmap.md index 084b48a2..6b95d87a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | Feature | Description | Status | |---------|-------------|--------| -| **User-defined functions** | Broader built-in scalar function registry (UPPER, ABS, COALESCE, etc.), user-registered C# functions, native plugin extensions | Planned | +| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back Admin Forms lifecycle events, command-button clicks, selected control events, Admin Reports render lifecycle events, and pipeline run hooks; Admin Forms now have declarative action sequences for run-command, set-field, show-message, and stop steps. Broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, richer macro flow, additional Access-style control events, and sandboxed UDFs remain future work | Partial | | **Subqueries** | Scalar subqueries, `IN (SELECT ...)`, `EXISTS (SELECT ...)`, including correlated evaluation in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions | Done | | **`UNION` / `INTERSECT` / `EXCEPT`** | Set operations across SELECT results, including use in top-level queries, views, and CTE query bodies | Done | | **Window functions** | `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `LEAD()`, `LAG()` | Planned | @@ -56,8 +56,8 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | **NuGet package** | Publish and maintain `CSharpDB.Engine`, `CSharpDB.Data`, `CSharpDB.Client`, and `CSharpDB.Primitives` as the primary NuGet packages | Done | | **Connection pooling** | Pool underlying direct embedded sessions behind `CSharpDbConnection` to amortize open/close cost | Done | | **Admin dashboard improvements** | Richer SQL editor UX, query history, deeper diagnostics, and integrated Forms/Reports tooling beyond the core schema/procedure/storage surface | Done | -| **Admin Forms Access parity** | Close the highest-impact Access-style form gaps: runtime responsive layouts, full inferred validation enforcement, richer record-source/filter/sort models, Layout View, form modes, command/action events, and broader control coverage | Planned | -| **Admin Reports Access parity** | Close the highest-impact Access-style report gaps: bounded saved-query previews, full report rendering/export, parameter/filter prompts, richer grouping/totals options, Layout View, conditional formatting, subreports, and broader report controls | Planned | +| **Admin Forms Access parity** | Close the highest-impact Access-style form gaps: runtime responsive layouts, full inferred validation enforcement, richer record-source/filter/sort models, Layout View, form modes, broader action/event coverage, and broader control coverage; trusted command-backed form lifecycle events, command buttons, and selected control events are now started | Partial | +| **Admin Reports Access parity** | Close the highest-impact Access-style report gaps: bounded saved-query previews, full report rendering/export, parameter/filter prompts, richer grouping/totals options, Layout View, conditional formatting, subreports, and broader report controls; trusted command-backed report preview lifecycle events are now started | Partial | | **Visual query designer** | Classic Admin query builder with source canvas, join editing, design grid, SQL preview, and saved designer layouts | Done | | **ETL pipelines** | Built-in package-driven pipeline runtime with validation, dry-run, execute/resume flows, API/CLI/client coverage, run history, and Admin visual designer support | Done | | **VS Code extension** | Schema explorer, SQL editor with IntelliSense, data browser, table designer, storage diagnostics | Done | @@ -85,6 +85,7 @@ Advanced features and fundamental architecture enhancements. | **Group commit / deferred WAL flush** | Done in `v2.9.0`: opt-in `UseDurableCommitBatchWindow(...)` batches durable WAL flushes across contending in-process transactions and remains an expert measure-first knob rather than default behavior | Done | | **Initial multi-writer support** | Explicit `WriteTransaction` conflict-detected retry flow, shared auto-commit non-insert isolation, and opt-in `ConcurrentWriteTransactions` for shared implicit inserts | Done | | **Broader multi-writer insert optimization** | Improve hot insert fan-in, row-id reservation, and other high-contention patterns beyond the current initial multi-writer path | Research | +| **API-level sharding** | Route API/daemon requests across multiple warm CSharpDB database files so independent tenants or shard keys can use separate WAL and commit paths, with v1 focused on single-shard writes and point reads | Research | | **Replication / change feed** | Stream committed changes for read replicas or event-driven architectures | Research | | **WebAssembly sandboxed UDFs** | Execute untrusted user-submitted functions in a WASM sandbox with resource limits (fuel, memory caps) via Wasmtime | Research | @@ -96,7 +97,7 @@ These are known simplifications in the current implementation: | Area | Limitation | |------|-----------| -| **Functions** | Very limited scalar function surface today: built-in `TEXT(expr)` plus aggregate functions; no broader built-in function library or user-defined functions yet | +| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host; Admin Forms, Admin Reports, and pipelines can invoke trusted host commands from supported event/hook surfaces; Admin Forms support declarative action sequences for run-command, set-field, show-message, and stop steps. Broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, additional Access-style control events, richer macro flow, and sandboxed UDFs are not implemented | | **Query** | Scalar/`IN`/`EXISTS` subqueries are supported, including correlated cases in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions; correlated subqueries are not yet supported in `JOIN ON`, `GROUP BY`, `HAVING`, `ORDER BY`, or aggregate projections | | **Query** | `UNION`, `INTERSECT`, and `EXCEPT` are supported; `UNION ALL` is not implemented yet | | **Query** | No window functions | @@ -106,8 +107,8 @@ These are known simplifications in the current implementation: | **Collections** | `FindByIndexAsync` supports declared field-equality lookups; `FindByPathAsync` and `FindByPathRangeAsync` support path-based queries on indexed paths; `FindAsync` remains a full scan for unindexed predicates | | **Networking** | `CSharpDB.Daemon` now hosts both REST and gRPC from one process; named pipes remain reserved but are not implemented end to end today | | **Security** | Remote HTTP and gRPC deployment still rely on external network controls or front-end TLS termination; built-in authentication, authorization, and TLS/mTLS support are still planned | -| **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, command/action events, advanced filtering/sorting, and broader controls | -| **Admin Reports** | The Reports designer/runtime supports the core banded preview path, but still needs Access-parity work for bounded saved-query previews, full report output/export, parameters, richer grouping and totals semantics, conditional formatting, subreports, and broader controls | +| **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path plus initial trusted command-backed automation, including lifecycle events, command buttons, selected control events, and declarative action sequences, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, broader built-in actions, additional events, advanced filtering/sorting, and broader controls | +| **Admin Reports** | The Reports designer/runtime supports the core banded preview path plus trusted command-backed preview lifecycle events, but still needs Access-parity work for bounded saved-query previews, full report output/export, parameters, richer grouping and totals semantics, conditional formatting, subreports, and broader controls | | **Text / Multilingual** | Text is stored as UTF-8 and supports all Unicode languages; default semantics remain ordinal, but opt-in `BINARY`, `NOCASE`, `NOCASE_AI`, and `ICU:` collation are implemented for SQL and collection indexes. Dedicated ordered SQL text index optimization remains planned | | **Concurrency** | The physical WAL commit path is still serialized at the storage boundary. Initial multi-writer support is shipped, but observed gains still depend on conflict shape and whether shared auto-commit `INSERT` is left on the default serialized path | | **Storage** | No page-level compression | @@ -182,6 +183,7 @@ Major features already implemented: - [Internals & Contributing](https://csharpdb.com/docs/internals.html) — How to extend the engine - [Deployment & Installation Plan](deployment/README.md) — Cross-platform distribution via dotnet tool, Docker, Homebrew, winget, and install scripts - [Multi-Writer Follow-Up Plan](multi-writer-follow-up-plan.md) — Post-initial multi-writer roadmap, insert-path gaps, and release criteria for broader completion +- [API-Level Sharding Plan](api-sharding.md) — API/daemon-level routing across multiple database files for write-throughput scaling - [Query And Durable Write Performance Plan](query-and-durable-write-performance/README.md) — Combined optimizer phase-2 plus durable-write completion plan, shipped state, and remaining benchmark/future-work boundaries - [Multilingual Text Support Plan](https://csharpdb.com/docs/collation-support.html) — Build on existing Unicode text storage with case-insensitive matching, locale-aware sorting, and `COLLATE` clause support for queries and index definitions - [Database Encryption Plan](database-encryption/README.md) — Encrypted storage format, key management, migration, and managed-surface rollout diff --git a/docs/sql-reference.md b/docs/sql-reference.md index a18c8b28..e2bb355c 100644 --- a/docs/sql-reference.md +++ b/docs/sql-reference.md @@ -313,6 +313,11 @@ SELECT COUNT(DISTINCT status), AVG(age) FROM users; |----------|-----------|---------|-------------| | `TEXT(expr)` | 1 | TEXT | Converts any value to its text representation | +Host applications can also register trusted in-process C# scalar functions and call +them from SQL expression positions such as `SELECT`, `WHERE`, `ORDER BY`, +`INSERT`, `UPDATE`, trigger bodies, and SQL procedure bodies. See +[Trusted C# Scalar Functions](trusted-csharp-functions/README.md). + --- ## Parameters diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md new file mode 100644 index 00000000..d7df8bb6 --- /dev/null +++ b/docs/trusted-csharp-functions/README.md @@ -0,0 +1,1036 @@ +# Trusted C# Functions And Commands + +CSharpDB can call host-registered C# scalar functions from SQL and the embedded expression surfaces that sit on top of the engine. This is the CSharpDB equivalent of an Access-style application function integration: the application owns the C# code, registers it while opening or hosting the database, and users call the function by name in database expressions. + +This feature is intentionally trusted and in-process. It does not store C# source code in the database, sandbox user code, load plugin assemblies from database files, or serialize delegates over HTTP or gRPC. + +For an end-to-end app-builder walkthrough that combines Admin Forms, collections, +macro actions, reports, trusted callbacks, and callback readiness, see the +[Fulfillment Ops Admin Automation tutorial](../tutorials/fulfillment-ops-admin-automation.md). + +For Admin Forms save-time business validation, see +[Trusted Validation Rules](validation-rules.md). + +--- + +## Trusted Commands + +CSharpDB also supports trusted host-registered commands for application automation surfaces. Commands are different from scalar functions: + +- Scalar functions return a `DbValue` and can be used inside SQL or formulas. +- Commands return a `DbCommandResult` and are invoked by host-driven events such as Admin Forms lifecycle events, Admin Reports render events, and pipeline run hooks. + +Commands are intended for Access-style application automation such as auditing, calling application services, sending notifications, refreshing derived state, coordinating UI workflows, or publishing operational run events. They are trusted in-process callbacks registered by the host application. + +```csharp +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; + +builder.Services.AddCSharpDbAdminForms(commands => +{ + commands.AddAsyncCommand( + "AuditCustomerChange", + new DbCommandOptions( + Description: "Writes an application audit entry.", + Timeout: TimeSpan.FromSeconds(10), + IsLongRunning: true), + static async (context, ct) => + { + long customerId = context.Arguments["Id"].AsInteger; + string eventName = context.Metadata["event"]; + + await WriteAuditAsync(customerId, eventName, ct); + return DbCommandResult.Success(); + }); +}); +``` + +Command names are case-insensitive identifiers. Duplicate command names are rejected during registration. + +Use `AddCommand(...)` for synchronous or `ValueTask`-returning callbacks and +`AddAsyncCommand(...)` for `Task` callbacks. Every command +receives a `CancellationToken`; host code should pass it to I/O calls and stop +work when cancellation is requested. + +`DbCommandOptions.Timeout` is optional. When set, CSharpDB cancels the command +token if the callback does not finish in time and reports a timeout through the +same surface-specific failure path as other command errors. `IsLongRunning` is +metadata for hosts and UI surfaces; it does not move the command out of process +or run it on a separate scheduler. + +Every registered command exposes a `DbHostCallbackDescriptor` through +`DbCommandDefinition.Descriptor` and `DbCommandRegistry.Callbacks`. The +descriptor is read-only metadata for policy checks, diagnostics, and Admin +visibility. It does not sandbox the command. + +--- + +## Trusted Validation Rules + +Admin Forms can also call host-registered validation rules before save. Rules +are registered in the host app with `AddCSharpDbAdminFormValidationRules(...)` +and referenced from form or control metadata by name. Missing, denied, timed +out, throwing, or failed validation rules block save and appear in the callback +diagnostics path. + +See [Trusted Validation Rules](validation-rules.md) for registration examples, +field-level and form-level metadata, policy grants, diagnostics behavior, and +the runnable sample. + +--- + +## What You Can Register + +V1 supports synchronous scalar functions: + +```csharp +public delegate DbValue DbScalarFunctionDelegate( + DbScalarFunctionContext context, + ReadOnlySpan arguments); +``` + +A scalar function receives database values and returns one database value. Supported value types are: + +| CSharpDB type | Read with | Return with | +| --- | --- | --- | +| `DbType.Integer` | `value.AsInteger` | `DbValue.FromInteger(...)` | +| `DbType.Real` | `value.AsReal` | `DbValue.FromReal(...)` | +| `DbType.Text` | `value.AsText` | `DbValue.FromText(...)` | +| `DbType.Blob` | `value.AsBlob` | `DbValue.FromBlob(...)` | +| `DbType.Null` | `value.IsNull` | `DbValue.Null` | + +Functions are registered with: + +```csharp +using CSharpDB.Engine; +using CSharpDB.Primitives; + +var options = new DatabaseOptions() + .ConfigureFunctions(functions => + { + functions.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => + DbValue.FromText(args[0].AsText.ToLowerInvariant().Replace(' ', '-'))); + }); +``` + +Open the database with those options: + +```csharp +await using var db = await Database.OpenAsync("app.db", options); +``` + +For tests or transient data: + +```csharp +await using var db = await Database.OpenInMemoryAsync(options); +``` + +--- + +## Complete Example + +```csharp +using CSharpDB.Engine; +using CSharpDB.Primitives; + +static string Slugify(string text) +{ + return text.Trim().ToLowerInvariant().Replace(' ', '-'); +} + +var options = new DatabaseOptions() + .ConfigureFunctions(functions => + { + functions.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText))); + + functions.AddScalar( + "IsEven", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Integer, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => + DbValue.FromInteger(args[0].AsInteger % 2 == 0 ? 1 : 0)); + }); + +await using var db = await Database.OpenAsync("app.db", options); + +await db.ExecuteAsync(""" + CREATE TABLE articles ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT + ); + """); + +await db.ExecuteAsync("INSERT INTO articles VALUES (1, 'Hello World', Slugify('Hello World'))"); +await db.ExecuteAsync("INSERT INTO articles VALUES (2, 'Second Post', Slugify('Second Post'))"); + +await using var result = await db.ExecuteAsync(""" + SELECT id, Slugify(title) + FROM articles + WHERE IsEven(id) = 1 + ORDER BY Slugify(title); + """); +``` + +### VS Code Host Project Workflow + +For a runnable C# host project, open +`samples/trusted-csharp-host` in VS Code. The sample includes `.vscode` launch +and task files so a developer can press `F5`, set breakpoints inside the +registered callbacks, and watch SQL and Admin Forms automation invoke ordinary +C# code. + +```powershell +dotnet run --project samples\trusted-csharp-host\TrustedCSharpHostSample.csproj +``` + +The sample demonstrates: + +- `DatabaseOptions.ConfigureFunctions(...)` for a trusted scalar function. +- SQL calling the function by name. +- `DbCommandRegistry` for a trusted host command. +- An Admin Forms `DbActionSequence` that sets a field and then runs the + command. + +The VS Code story stays host-owned: VS Code is the editor/debugger for the C# +host project, while database metadata stores names and declarative action data +only. + +### End-To-End Developer Handoff + +The production workflow is a handoff between an app builder and a host +developer: + +1. The app builder creates database metadata that references a callback by name. + Examples include a formula such as `=Slugify([Name])` or a form action step + such as `RunCommand` with command name `SendOpsDigest`. +2. Admin records only the callback name, arity/kind metadata, action arguments, + and reference location. It does not store C# source. +3. The callback catalog compares referenced names with the callbacks registered + by the current host. Missing names appear as missing callback readiness. +4. The app builder copies the generated registration stub from Admin and gives + it to the host developer. +5. The host developer implements the C# function or command in the host + project, registers it during startup, and debugs it in VS Code like normal + application code. +6. The host is restarted. Admin refreshes callback readiness, and the reference + changes from missing to registered/allowed when the name, kind, and arity + match. + +For Admin itself, host-owned demo callbacks are registered in +`src/CSharpDB.Admin/Services/AdminHostCallbacks.cs`. A scalar function is +registered with `DbFunctionRegistry`: + +```csharp +functions.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText))); +``` + +A command callback is registered with `DbCommandRegistry`: + +```csharp +commands.AddCommand( + "SendOpsDigest", + new DbCommandOptions("Sends a fulfillment operations digest."), + static context => + { + string source = context.Arguments.TryGetValue("source", out DbValue value) + ? value.AsText + : "unknown"; + + // Call host-owned services here. + return DbCommandResult.Success($"Digest requested by {source}."); + }); +``` + +That C# code belongs to the host application. The database still stores only the +name `SendOpsDigest` and any declarative action arguments. + +### Stored Procedures In The Mix + +Stored procedures are different from trusted callbacks. They are database-owned +SQL definitions with parameter metadata. They are useful when the logic should +stay inside CSharpDB and can be expressed as SQL: + +| Need | Prefer | +| --- | --- | +| Reusable multi-statement table work | Stored procedure | +| Transactional updates plus follow-up result sets | Stored procedure | +| A form button that runs reviewed database logic | `RunProcedure` | +| External API, email, filesystem, queue, or host service call | Trusted command callback | +| Custom scalar calculation inside SQL expressions | Trusted scalar function | +| UI-only behavior such as filtering or control state | Declarative macro action | + +A stored procedure can be executed directly through the client API: + +```csharp +ProcedureExecutionResult result = await client.ExecuteProcedureAsync( + "AllocateOrder", + new Dictionary + { + ["orderId"] = 7005, + ["allocatedBy"] = "Wave Planner", + ["note"] = "Allocated from a reviewed procedure.", + }); +``` + +Admin's SQL editor also accepts `EXEC` as an Admin command surface: + +```sql +EXEC AllocateOrder @orderId = 7005, @allocatedBy = 'Wave Planner'; +EXEC RefreshOperationalStats; +EXEC tutorial_OpenOrderSnapshot { "status": "released" }; +``` + +Use `RunProcedure` from form metadata only when the rendered host enables +procedure actions. Use `RunCommand` when the same button needs host-owned C#. +When both are needed, run the procedure first for database work and the command +second for the external side effect. + +--- + +## Registration Rules + +Function names are SQL identifiers: + +- They must start with a letter or `_`. +- Remaining characters must be letters, digits, or `_`. +- Lookup is case-insensitive, so `Slugify`, `slugify`, and `SLUGIFY` refer to the same function. +- A user function name can only be registered once. V1 does not support overloads by arity. +- Reserved built-ins cannot be overridden. Current reserved names are `TEXT`, `COUNT`, `SUM`, `AVG`, `MIN`, and `MAX`. +- `arity` must match the number of arguments used by the expression. + +Registration failures throw immediately so host applications fail at startup instead of later during a query. + +`ConfigureFunctions` sets the function registry for the returned `DatabaseOptions`. If you chain multiple option helpers, keep all function registrations in one `ConfigureFunctions` call or assign a single `DbFunctionRegistry` to `DatabaseOptions.Functions`. + +--- + +## Function Options + +Each function can include `DbScalarFunctionOptions`: + +```csharp +new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true, + Description: "Formats a URL slug.", + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Clock) + ]) +``` + +| Option | Meaning | +| --- | --- | +| `ReturnType` | Optional metadata describing the expected return type. | +| `IsDeterministic` | Marks the function as returning the same output for the same inputs. V1 exposes the metadata but does not use it for constant folding or index planning. | +| `NullPropagating` | If any argument is `NULL`, CSharpDB returns `NULL` without invoking the delegate. | +| `Description` | Optional human-readable text for host tools and Admin visibility. | +| `AdditionalCapabilities` | Optional capability metadata beyond the implicit `ScalarFunctions` capability. This is for CSharpDB policy mediation and visibility, not a .NET sandbox. | +| `Metadata` | Optional host-defined descriptor metadata. | + +Without `NullPropagating`, `DbValue.Null` is passed to the delegate and the function decides what to do. + +```csharp +functions.AddScalar( + "CoalesceText", + arity: 2, + options: new DbScalarFunctionOptions(DbType.Text), + invoke: static (_, args) => + args[0].IsNull ? args[1] : args[0]); +``` + +Registered scalar functions expose `DbScalarFunctionDefinition.Descriptor` and +`DbFunctionRegistry.Callbacks`. The descriptor always uses +`DbExtensionRuntimeKind.HostCallback`, records the callback kind, name, arity, +return type, deterministic/null behavior, and includes the implicit +`ScalarFunctions` capability plus any additional capabilities declared in the +options. + +Commands expose the same descriptor shape through `DbCommandDefinition`. +Command descriptors include the implicit `Commands` capability plus any +additional capabilities declared in `DbCommandOptions`, along with description, +timeout, and long-running metadata. + +Hosts can evaluate descriptor capabilities with `DbExtensionPolicyEvaluator`: + +```csharp +DbHostCallbackDescriptor descriptor = commandRegistry.Callbacks.Single(); +DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + descriptor, + new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Granted), + ]), + DbExtensionHostMode.Embedded); +``` + +Policy evaluation controls what CSharpDB-mediated APIs should allow. It does +not restrict arbitrary in-process .NET calls made by trusted host code. + +--- + +## SQL Usage + +Registered scalar functions can be used in non-aggregate SQL expression positions: + +```sql +SELECT Slugify(title) FROM articles; +SELECT * FROM articles WHERE IsEven(id) = 1; +SELECT * FROM articles ORDER BY Slugify(title); +INSERT INTO articles VALUES (3, 'New Title', Slugify('New Title')); +UPDATE articles SET slug = Slugify(title) WHERE slug IS NULL; +``` + +They also work in trigger bodies and SQL procedure bodies because those paths execute through the same SQL expression evaluator: + +```sql +CREATE TABLE article_audit (article_id INTEGER, slug TEXT); + +CREATE TRIGGER articles_ai AFTER INSERT ON articles +BEGIN + INSERT INTO article_audit VALUES (NEW.id, Slugify(NEW.title)); +END; +``` + +Custom functions stay on the residual expression path in V1: + +- No index pushdown is inferred from a custom function. +- No generated-column or expression-index behavior is added. +- No constant folding or cost assumptions are made from custom function metadata. + +That keeps existing query and storage paths unchanged unless a query actually calls a registered function. + +--- + +## Direct Client Usage + +Direct clients pass functions through `DirectDatabaseOptions`: + +```csharp +using CSharpDB.Client; +using CSharpDB.Engine; +using CSharpDB.Primitives; + +await using var client = CSharpDbClient.Create(new CSharpDbClientOptions +{ + DataSource = "app.db", + DirectDatabaseOptions = new DatabaseOptions() + .ConfigureFunctions(functions => + { + functions.AddScalar( + "AddOne", + 1, + new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromInteger(args[0].AsInteger + 1)); + }), +}); + +await client.ExecuteSqlAsync("CREATE TABLE numbers (value INTEGER);"); +await client.ExecuteSqlAsync("INSERT INTO numbers VALUES (41);"); + +var result = await client.ExecuteSqlAsync("SELECT AddOne(value) FROM numbers;"); +``` + +`DirectDatabaseOptions` is only valid for direct transport. It is rejected for HTTP and gRPC clients because delegates cannot be serialized to another process. + +--- + +## Remote Host Usage + +HTTP and gRPC clients cannot send C# delegates. Remote SQL can call a custom function only when that function is registered inside the host process that owns the database. + +The practical rule is: + +- Embedded or direct client: register functions in `DatabaseOptions` or `DirectDatabaseOptions`. +- Remote client: register functions where the daemon, API host, or application server opens the database. +- Pipeline packages, report definitions, form definitions, procedures, and SQL text store function names and expressions only. They do not store C# function bodies. +- Admin Forms, Admin Reports, and pipeline packages also store generated `automation` metadata that lists required trusted commands and scalar functions by name, surface, and location. This is an import/export contract for hosts; it is not executable code. + +--- + +## Admin Forms + +Admin Forms computed formulas can call registered scalar functions when the formula evaluator receives a `DbFunctionRegistry`. + +```csharp +using CSharpDB.Admin.Forms.Evaluation; +using CSharpDB.Primitives; + +var functions = DbFunctionRegistry.Create(builder => +{ + builder.AddScalar( + "Tax", + 1, + new DbScalarFunctionOptions(DbType.Real, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromReal(args[0].AsReal * 0.0825)); +}); + +double? tax = FormulaEvaluator.Evaluate( + "=Tax(Subtotal)", + fieldResolver: name => name == "Subtotal" ? 100.00 : null, + functions: functions); +``` + +Forms formulas are numeric formulas. A custom function used from `FormulaEvaluator.Evaluate` should return `INTEGER` or `REAL`; other return types evaluate to `null` in that surface. Existing aggregate formulas such as `=SUM(OrderItems.LineTotal)` remain built-in form behavior and are not replaced by custom scalar functions. + +Admin Forms can also bind lifecycle events to trusted commands. Form definitions store event names and command names only; the C# command bodies stay registered in the host process. + +```csharp +var form = existingForm with +{ + EventBindings = + [ + new FormEventBinding(FormEventKind.OnOpen, "AuditFormOpen"), + new FormEventBinding(FormEventKind.BeforeInsert, "ValidateCustomerCreate"), + new FormEventBinding(FormEventKind.AfterUpdate, "AuditCustomerChange"), + ], +}; +``` + +Supported form-level events in this slice are: + +| Event | When it runs | +| --- | --- | +| `OnOpen` | After the form definition and source table are resolved, before records load. | +| `OnLoad` | After the initial record page loads. | +| `BeforeInsert` | Before a new record is inserted. Returning `DbCommandResult.Failure(...)` cancels the insert. | +| `AfterInsert` | After a new record is inserted. | +| `BeforeUpdate` | Before an existing record is updated. Returning failure cancels the update. | +| `AfterUpdate` | After an existing record is updated. | +| `BeforeDelete` | Before the current record is deleted. Returning failure cancels the delete. | +| `AfterDelete` | After the current record is deleted. | + +Command context arguments include the current record fields converted to `DbValue`. Static arguments configured on the event binding override same-named record fields. Metadata includes `surface`, `formId`, `formName`, `tableName`, and `event`. + +The Admin Forms designer preserves form event bindings and exposes them in the property inspector when no control is selected. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. The same editor can attach a visual action sequence to the event. + +Admin Forms also include control-level trusted command events. Form controls store event names, command names, and optional JSON arguments in the form definition. At runtime, the renderer invokes the registered host command with the current record fields plus event-specific arguments. + +```csharp +var textBox = existingTextBox with +{ + EventBindings = + [ + new ControlEventBinding( + ControlEventKind.OnChange, + "NormalizeCustomerName", + new Dictionary { ["source"] = "name-textbox" }), + new ControlEventBinding(ControlEventKind.OnLostFocus, "ValidateCustomerName"), + ], +}; +``` + +Supported control events in this slice are: + +| Event | When it runs | +| --- | --- | +| `OnClick` | When a label or command button is clicked. | +| `OnChange` | After an input, checkbox, radio, select, lookup, or textarea updates its bound field. | +| `OnGotFocus` | When an interactive control receives focus. | +| `OnLostFocus` | When an interactive control loses focus. | + +Control event metadata includes the Forms metadata plus `event`, `controlId`, `controlType`, and `fieldName` for bound controls. Arguments include current record fields and event details such as `fieldName`, `value`, and `oldValue` for field changes. Static arguments configured on the event binding override same-named runtime arguments. + +The Admin Forms designer exposes selected-control event bindings in the property inspector. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. Selected-control events use the same visual action-sequence editor as form lifecycle events. + +Admin Forms also include a command button control. Command buttons store a display label, a command name, and optional JSON arguments in the form definition. At runtime, clicking the button invokes the registered host command with the current record fields plus the configured arguments. Command buttons can also use `ControlEventKind.OnClick` bindings, which allows a button to be driven entirely by the shared control-event model. + +```csharp +var button = new ControlDefinition( + "btn-ship", + "commandButton", + new Rect(24, 320, 160, 34), + Binding: null, + Props: new PropertyBag(new Dictionary + { + ["text"] = "Ship Order", + ["commandName"] = "ShipOrder", + ["commandArguments"] = new Dictionary + { + ["source"] = "form-button", + }, + }), + ValidationOverride: null); +``` + +Command button direct-command metadata includes the same form metadata as lifecycle events, plus `event = "Click"`, `controlId`, and `controlType`. + +--- + +## Declarative Admin Forms Action Sequences + +Admin Forms event bindings can also store small declarative action sequences. +This is the first Access-style macro layer for CSharpDB Forms: the form stores +action metadata, while any executable C# still lives in host-registered trusted +commands. + +```csharp +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +var shipButton = existingButton with +{ + EventBindings = + [ + new ControlEventBinding( + ControlEventKind.OnClick, + CommandName: string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.SetFieldValue, + Target: "Status", + Value: "Shipped"), + new DbActionStep( + DbActionKind.RunCommand, + CommandName: "AuditOrderStatus", + Condition: "Status = 'Shipped'", + Arguments: new Dictionary + { + ["source"] = "ship-button", + }), + new DbActionStep( + DbActionKind.ShowMessage, + Message: "Order marked as shipped."), + ], + Name: "ShipButtonActions")), + ], +}; +``` + +The action set is intentionally small and form-focused: + +| Action | Behavior | +| --- | --- | +| `RunCommand` | Invokes a host-registered trusted command by name. | +| `RunActionSequence` | Invokes a reusable named form action sequence stored on the form definition. | +| `SetFieldValue` | Updates a target field in the current mutable form record. | +| `ShowMessage` | Sends a message when the current Forms surface provides a command/message callback. | +| `Stop` | Ends the current sequence successfully. | +| `NewRecord` | Starts a new record in the rendered form. | +| `SaveRecord` | Saves the current rendered record through the normal form save path. | +| `DeleteRecord` | Deletes the current persisted rendered record through the normal form delete path. | +| `RefreshRecords` | Reloads the current record/page while preserving the current primary key when possible. | +| `PreviousRecord` | Moves the rendered form to the previous record. | +| `NextRecord` | Moves the rendered form to the next record. | +| `GoToRecord` | Navigates to a primary-key value from `Value`, `Arguments["value"]`, `Arguments["recordId"]`, `Arguments["primaryKey"]`, or the field named by `Target`. | + +Reusable action sequences are stored once on the form and invoked by name from +form events, control events, or command buttons: + +```csharp +var form = existingForm with +{ + ActionSequences = + [ + new DbActionSequence( + [ + new DbActionStep(DbActionKind.SetFieldValue, Target: "Status", Value: "Ready"), + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditReady"), + ], + Name: "PrepareReadyStatus"), + ], + EventBindings = + [ + new FormEventBinding( + FormEventKind.BeforeUpdate, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.RunActionSequence, + SequenceName: "PrepareReadyStatus", + Arguments: new Dictionary { ["source"] = "before-update" }), + ])), + ], +}; +``` + +`RunActionSequence` arguments become runtime arguments for the nested sequence, +so nested `RunCommand` steps receive current record fields, binding arguments, +caller-supplied sequence arguments, and their own step arguments. Recursive +sequence loops fail with a nesting-limit error instead of running forever. + +Action sequences can be attached to form lifecycle bindings or selected-control +bindings. A binding can contain only a command, only an action sequence, or a +command followed by an action sequence: + +```csharp +var form = existingForm with +{ + EventBindings = + [ + new FormEventBinding( + FormEventKind.BeforeInsert, + "ValidateOrder", + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.SetFieldValue, + Target: "Status", + Value: "Draft"), + ])), + ], +}; +``` + +The Admin Forms property inspector exposes action sequences with a visual +editor on form-level and selected-control event bindings. Designers can add a +sequence, name it, add command, reusable-sequence, field, message, stop, and +built-in record steps, reorder or remove steps, choose registered commands or +reusable sequences when available, and set per-step conditions and +`StopOnFailure`. The form-level property inspector also includes a reusable +action-sequence library editor. JSON editing remains only for optional binding, +`RunCommand`, or `RunActionSequence` argument payloads. + +For `RunCommand`, command arguments are built from current record fields, +binding arguments, runtime event arguments, and step arguments, with later +sources overriding earlier ones. Command metadata includes the Forms metadata +plus `actionKind`, `actionStep`, optional `actionSequence`, and optional +`actionCondition`. + +Every action step can include a `Condition`. Empty conditions run the step. +False conditions skip only that step. Malformed conditions fail through the +normal step failure path, so `StopOnFailure = false` can allow a later step to +continue. + +Supported condition syntax is intentionally small: + +| Syntax | Example | +| --- | --- | +| Truthy value | `IsActive` | +| Equality | `Status = 'Ready'` or `[Status] == "Ready"` | +| Inequality | `Status <> 'Closed'` or `Status != 'Closed'` | +| Numeric comparison | `Amount > 0`, `Quantity <= 10` | +| Null comparison | `ClosedAt = null` | + +Condition values are resolved from current record fields, binding arguments, +runtime event arguments, and step arguments using the same later-wins order as +command arguments. A leading `=` is accepted for macro-style conditions, for +example `=Status = 'Ready'`. + +When forms are saved through `DbFormRepository` or exported through +`FormAutomationMetadata.NormalizeForExport(...)`, the definition's `automation` +metadata is regenerated from form events, command buttons, selected-control +events, reusable action sequences, action-sequence `RunCommand` steps, and +computed-control formulas. Older form JSON without automation metadata is +backfilled when it is loaded. + +`SetFieldValue` can update mutable records in form lifecycle events such as +`BeforeInsert` and `BeforeUpdate`, and it can update the current rendered record +from control events or command-button clicks. + +Built-in record actions require a rendered Admin Forms data-entry runtime. +They are intended for command buttons and selected-control events. Headless +form lifecycle dispatch can still run `SetFieldValue`, `ShowMessage`, `Stop`, +and `RunCommand`, but it reports a failure if a sequence asks for rendered-form +navigation or save/delete actions. + +Action sequences do not include loops, stored C# source, database-owned +plugins, or remote delegate serialization. Rendered Admin form runtimes support +direct SQL and procedure actions only when the host explicitly enables those +capabilities. + +--- + +## Admin Reports + +Admin Reports preview rendering accepts the same registry through `DefaultReportPreviewService`: + +```csharp +using CSharpDB.Admin.Reports.Services; +using CSharpDB.Primitives; + +var previewService = new DefaultReportPreviewService( + dbClient, + sourceProvider, + functions); +``` + +Numeric calculated expressions can call numeric-returning functions: + +```text +=Tax([Subtotal]) +``` + +Calculated text can use a scalar function as the whole expression, including text-returning functions: + +```text +=FormatInvoiceLabel([InvoiceNumber], [CustomerName]) +``` + +Report aggregate formulas such as `=SUM([Subtotal])` remain built-in report behavior. + +Admin Reports can also bind preview-render lifecycle events to trusted commands. Report definitions store event names, command names, and optional static arguments only; the C# command bodies stay registered by the host process. + +```csharp +using CSharpDB.Admin.Reports.Models; + +var report = existingReport with +{ + EventBindings = + [ + new ReportEventBinding(ReportEventKind.OnOpen, "AuditReportOpen"), + new ReportEventBinding(ReportEventKind.BeforeRender, "PrepareReportContext"), + new ReportEventBinding(ReportEventKind.AfterRender, "PublishReportRendered"), + ], +}; +``` + +Supported report events are: + +| Event | When it runs | +| --- | --- | +| `OnOpen` | After the report source is resolved, before preview rows are loaded. | +| `BeforeRender` | After preview rows are loaded and capped, before pagination and calculated text rendering. | +| `AfterRender` | After preview pages are produced, before the preview result is returned. | + +Command context arguments include render metrics such as `rowCount`, `loadedRowCount`, `rowTruncated`, `pageCount`, `isTruncated`, and `hasSchemaDrift` depending on the event. Static arguments configured on the binding override same-named runtime arguments. Metadata includes `surface = AdminReports`, `reportId`, `reportName`, `sourceKind`, `sourceName`, and `event`. + +When reports are saved through `DbReportRepository` or exported through +`ReportAutomationMetadata.NormalizeForExport(...)`, the definition's +`automation` metadata is regenerated from report event bindings and calculated +text expressions. Older report JSON without automation metadata is backfilled +when it is loaded. + +Register report commands through the reports service registration overload: + +```csharp +using CSharpDB.Admin.Reports.Services; +using CSharpDB.Primitives; + +builder.Services.AddCSharpDbAdminReports(commands => +{ + commands.AddCommand("PublishReportRendered", static context => + { + string reportName = context.Metadata["reportName"]; + long pageCount = context.Arguments["pageCount"].AsInteger; + + PublishReportMetric(reportName, pageCount); + return DbCommandResult.Success(); + }); +}); +``` + +--- + +## Pipelines + +Pipelines can call registered scalar functions in filter expressions and derived-column expressions when the runner or component factory is constructed with a registry. + +```csharp +using CSharpDB.Client.Pipelines; +using CSharpDB.Pipelines.Models; +using CSharpDB.Primitives; + +var functions = DbFunctionRegistry.Create(builder => +{ + builder.AddScalar( + "NormalizeStatus", + 1, + new DbScalarFunctionOptions(DbType.Text, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromText(args[0].AsText.Trim().ToLowerInvariant())); +}); + +var runner = new CSharpDbPipelineRunner(client, functions); + +var package = new PipelinePackageDefinition +{ + Name = "active-customers", + Version = "1.0.0", + Source = new PipelineSourceDefinition + { + Kind = PipelineSourceKind.CsvFile, + Path = "customers.csv", + HasHeaderRow = true, + }, + Transforms = + [ + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Filter, + FilterExpression = "NormalizeStatus(status) == 'active'", + }, + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Derive, + DerivedColumns = + [ + new PipelineDerivedColumn + { + Name = "status_key", + Expression = "NormalizeStatus(status)", + }, + ], + }, + ], + Destination = new PipelineDestinationDefinition + { + Kind = PipelineDestinationKind.JsonFile, + Path = "active-customers.json", + }, +}; + +await runner.RunPackageAsync(package); +``` + +Pipeline package JSON stores expressions such as `NormalizeStatus(status)` plus generated `automation` metadata listing the required scalar function names. The C# delegate must be registered by the process that runs the package. + +Pipelines can also invoke trusted commands from run hooks. Hook definitions are serialized with the package, but they store only the hook event, command name, optional static arguments, and generated automation metadata: + +```csharp +var commands = DbCommandRegistry.Create(builder => +{ + builder.AddCommand("NotifyPipeline", static context => + { + string pipelineName = context.Metadata["pipelineName"]; + string status = context.Arguments["status"].AsText; + long rowsWritten = context.Arguments["rowsWritten"].AsInteger; + + NotifyOps(pipelineName, status, rowsWritten); + return DbCommandResult.Success(); + }); +}); + +var runner = new CSharpDbPipelineRunner(client, functions, commands); + +var package = new PipelinePackageDefinition +{ + Name = "active-customers", + Version = "1.0.0", + Source = new PipelineSourceDefinition + { + Kind = PipelineSourceKind.CsvFile, + Path = "customers.csv", + }, + Destination = new PipelineDestinationDefinition + { + Kind = PipelineDestinationKind.JsonFile, + Path = "active-customers.json", + }, + Hooks = + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunSucceeded, + CommandName = "NotifyPipeline", + Arguments = new Dictionary + { + ["channel"] = "ops", + }, + }, + ], +}; +``` + +Supported pipeline hook events are: + +| Event | When it runs | +| --- | --- | +| `OnRunStarted` | After package validation and run logging, before components are created. | +| `OnBatchCompleted` | After each source batch is transformed/written, metrics and checkpoints are updated, and reject limits pass. | +| `OnRunSucceeded` | After destination completion and before the successful run is logged as completed. | +| `OnRunFailed` | When the orchestrator is about to return a failed `PipelineRunResult`. | + +Hook arguments include `runId`, `pipelineName`, `pipelineVersion`, `mode`, `event`, `status`, `rowsRead`, `rowsWritten`, `rowsRejected`, and `batchesCompleted`. Batch hooks also include `batchNumber`, `startingRowNumber`, and `batchRowCount`. Failure hooks include `errorSummary`. Metadata includes `surface = Pipelines`, `pipelineName`, `pipelineVersion`, `runId`, `mode`, and `event`. + +`PipelinePackageSerializer` refreshes the `automation` manifest when packages are serialized, saved, deserialized, or loaded from disk. `PipelinePackageValidator` accepts older packages without a manifest, but if a manifest is present and no longer matches the package expressions/hooks, validation reports stale automation metadata so the package can be re-exported. + +`Validate` mode does not invoke command hooks, so package validation stays side-effect free. Missing command registration or a failing hook with `StopOnFailure = true` fails the run normally. For `OnRunFailed`, hook failures are appended to the failed run's error summary instead of recursively dispatching more failure hooks. + +--- + +## Error Handling + +Missing SQL functions fail with the existing unknown scalar function error. Function exceptions are wrapped with the function name and the surrounding statement follows normal rollback behavior. + +```csharp +functions.AddScalar( + "RequirePositive", + 1, + new DbScalarFunctionOptions(DbType.Integer, NullPropagating: true), + static (context, args) => + { + long value = args[0].AsInteger; + if (value <= 0) + throw new ArgumentOutOfRangeException(context.FunctionName, "Value must be positive."); + + return DbValue.FromInteger(value); + }); +``` + +For SQL write statements, a failing function aborts the statement. If the statement is inside a transaction, normal transaction rollback rules apply. + +Admin Forms formulas intentionally return `null` for invalid formulas, unsupported function return types, missing functions, division by zero, or exceptions. Pipeline functions throw runtime errors unless the pipeline error mode handles the affected row. + +Trusted command failures are surface-specific. Form before-events can cancel writes, report event failures fail preview rendering, and pipeline hook failures produce a failed `PipelineRunResult` unless the binding sets `StopOnFailure = false`. Timed-out commands are reported as command failures; caller-requested cancellation still propagates as cancellation instead of being converted to a failure message. + +Forms action-sequence failures follow the same binding-level `StopOnFailure` +rule. Step-level `StopOnFailure = false` lets a later step continue after that +step fails; otherwise the sequence reports the failure to the surrounding form +or control event. + +--- + +## Performance Guidance + +Custom functions run only when an expression calls them. Queries and writes that do not use custom functions stay on the existing paths. + +For low overhead: + +- Prefer `NullPropagating = true` when a function naturally returns null for null input. +- Avoid database calls, blocking I/O, sleeps, and long network calls inside delegates. +- For command callbacks that call application services, prefer `AddAsyncCommand(...)`, honor the provided cancellation token, and set a timeout that matches the user-facing workflow. +- Keep delegates thread-safe. A function may be called by concurrent queries in the same host process. +- Capture immutable services or thread-safe services in closures when application integration is needed. +- Use `IsDeterministic = true` for accurate metadata, but do not rely on V1 to optimize from it. + +--- + +## Current Limitations + +V1 does not support: + +- Aggregate UDFs. +- Table-valued UDFs. +- Stored C# source code or database-owned compiled modules. +- Sandboxed execution. +- Async scalar delegates. +- Passing a database handle into the function context. +- Sending delegates over HTTP, gRPC, or pipeline package files. +- Optimizer pushdown, expression indexes, generated columns, or constant folding based on custom function metadata. +- Additional Access-style control events such as double-click, key, mouse, timer, and dirty/current events. +- Richer macro/action scripts with loops, reusable UI rule presets, additional event surfaces, or database-owned executable code. diff --git a/docs/trusted-csharp-functions/access-style-macro-actions.md b/docs/trusted-csharp-functions/access-style-macro-actions.md new file mode 100644 index 00000000..21411eb1 --- /dev/null +++ b/docs/trusted-csharp-functions/access-style-macro-actions.md @@ -0,0 +1,116 @@ +# Access-Style Macro Actions + +Phase 8 extends Admin Forms action sequences with Access-style UI and data actions. The action metadata still stays declarative: host applications own executable C# callbacks, SQL/procedure opt-in policy, database connections, and navigation behavior. + +## Supported Actions + +| Action | Runtime behavior | +| --- | --- | +| `openForm` | Resolves a form by id or name and asks the host to open it. Arguments can include `mode`, `recordId`, `primaryKey`, `id`, `filter`, or `where`. | +| `closeForm` | Asks the host to close the active form entry tab or surface. | +| `applyFilter` | Filters the current form record list when `target` is `form`; filters a rendered `datagrid` control when `target` is that control id. | +| `clearFilter` | Clears the form or data-grid filter selected by `target`. | +| `runSql` | Executes SQL only when the host enables SQL actions. `@name` parameters are resolved from action arguments. | +| `runProcedure` | Executes a named database procedure only when the host enables procedure actions. `target` is the procedure name; action arguments become procedure arguments. | +| `setControlProperty` | Overrides rendered control properties such as `visible`, `enabled`, `readOnly`, `text`, `placeholder`, and bound `value`. | +| `setControlVisibility`, `setControlEnabled`, `setControlReadOnly` | Short forms for the corresponding `setControlProperty` calls. | + +Existing actions such as `setFieldValue`, `runCommand`, `runActionSequence`, `newRecord`, `saveRecord`, `deleteRecord`, `refreshRecords`, `previousRecord`, `nextRecord`, and `goToRecord` continue to work in the same action sequence model. + +## Filter Expressions + +Filters use the same bracketed field expression style as conditions: + +```json +{ + "kind": "applyFilter", + "target": "ordersGrid", + "value": "[Status] = @status AND [Total] > @minimum", + "arguments": { + "status": "Open", + "minimum": 100 + } +} +``` + +Use `target: "form"` for the parent form list. Use a DataGrid control id for child-row filtering. + +## Open Form Arguments + +`openForm` carries navigation arguments to the host: + +```json +{ + "kind": "openForm", + "target": "Orders Entry", + "arguments": { + "recordId": "$record.Id", + "mode": "view", + "filter": "[Status] = 'Open'" + } +} +``` + +The built-in admin tab host forwards those values to `DataEntry` as initial state. `mode: "new"` starts a writable form on a new record. `recordId` navigates to the requested primary key after load. `filter` or `where` applies an initial form filter. + +## SQL And Procedure Actions + +Use `runProcedure` when the workflow should invoke reusable database-owned SQL, +for example allocating an order, receiving a purchase order, processing a +return, or returning a packaged operational snapshot. A procedure body can run +multiple SQL statements in one execution and can return follow-up result sets. + +```json +{ + "kind": "runProcedure", + "target": "AllocateOrder", + "arguments": { + "orderId": 7005, + "allocatedBy": "Wave Planner", + "note": "Allocated from form action sequence." + }, + "stopOnFailure": true +} +``` + +Use `runCommand` instead when the workflow needs host-owned C# behavior such as +email, queues, external APIs, filesystem access, or other services. A common +sequence is `runProcedure` for database updates followed by `runCommand` for a +host notification. + +Rendered Admin form runtimes can leave `runSql` and `runProcedure` disabled by +policy. That keeps database-mutating actions explicit at the host boundary even +though procedure definitions themselves are database metadata. + +## Conditional UI Rules + +Form-level `rules` apply control property effects whenever their condition is true: + +```json +{ + "ruleId": "archived-state", + "condition": "[Status] = 'Archived'", + "effects": [ + { "controlId": "statusBox", "property": "readOnly", "value": true }, + { "controlId": "archiveButton", "property": "enabled", "value": false } + ] +} +``` + +The designer property inspector includes a rules editor and a validation panel for action/rule readiness. + +## Diagnostics + +Subscribe to `FormActionDiagnostics.Listener` to observe action execution: + +```csharp +using CSharpDB.Admin.Forms.Contracts; + +using IDisposable subscription = FormActionDiagnostics.Listener.Subscribe(observer); +``` + +Events use `FormActionDiagnostics.InvocationEventName` and carry `FormActionInvocationDiagnostic`, including action kind, target, form id, event name, action sequence name, step index, elapsed time, success state, cancellation state, result message, exception message, and metadata. + +## Sample + +See `samples/trusted-csharp-host/access-style-macro-form.json` for a form manifest that combines open form, data-grid filtering, SQL execution, control property changes, and conditional UI rules. diff --git a/docs/trusted-csharp-functions/database-code-modules-vscode-plan.md b/docs/trusted-csharp-functions/database-code-modules-vscode-plan.md new file mode 100644 index 00000000..1a473417 --- /dev/null +++ b/docs/trusted-csharp-functions/database-code-modules-vscode-plan.md @@ -0,0 +1,664 @@ +# Database Code Modules With VS Code Sync Plan + +## Summary + +Add database-owned C# code modules that can be saved with a CSharpDB database, +compiled by CSharpDB, and invoked by Admin Forms events. Editing and debugging +should happen in VS Code, not in the Admin browser UI. + +This is the Access-style "code travels with the database" model, but with C# +and normal developer tooling. Admin remains the app-builder and runtime surface. +VS Code remains the serious code editor and debugger. + +The core implementation must be a public .NET API, not a CLI-first design. + +## Product Position + +CSharpDB will have three separate automation lanes: + +| Lane | Who Owns Code | Where It Lives | Use For | +| --- | --- | --- | --- | +| Declarative macros/actions | Database metadata | Form JSON/database metadata | No-code app behavior, navigation, field updates, command orchestration | +| Host callbacks | Host application | C# host project startup registration | External services, compiled integrations, application-owned code | +| Database code modules | Database | Database module metadata, synced to files for editing | Access-like form/business logic that travels with the database | + +Database code modules are not a replacement for host callbacks. Host callbacks +remain the right path for application services, email, queues, filesystem, +network calls, or privileged host operations. + +## Goals + +- Store C# source modules in the database. +- Let Admin create form/control event stubs. +- Let VS Code edit synced `.cs` files with normal C# tooling. +- Sync source between the database and a local workspace folder. +- Compile modules with Roslyn and return diagnostics. +- Surface diagnostics in Admin and VS Code. +- Execute trusted compiled modules from Admin Forms runtime events. +- Keep the core reusable through a public API based on `ICSharpDbClient`. +- Avoid Monaco/CodeMirror bloat in Admin. +- Keep CLI optional; it is not the primary architecture. + +## Non-Goals For V1 + +- No full in-browser IDE. +- No Monaco dependency for Admin code editing. +- No arbitrary hidden execution from untrusted databases. +- No remote dynamic assembly loading from form JSON. +- No debugger implemented inside Admin. +- No replacement for host callback integrations. +- No direct unmanaged/native code from modules. +- No unrestricted file/network/process APIs from database-owned code. + +## High-Level Architecture + +```text +CSharpDB.CodeModules + public .NET API + module metadata model + import/export/sync services + Roslyn build/diagnostic services + trusted runtime contracts + uses ICSharpDbClient + +CSharpDB.Admin + lists modules + creates event stubs + shows trust/build/diagnostic state + invokes compiled trusted module procedures at runtime + uses CSharpDB.CodeModules directly + +VS Code Extension + module tree + open/sync files + Problems diagnostics + build command + debug harness command + talks to .NET sidecar/language server + +.NET Sidecar / Language Server + references CSharpDB.CodeModules + exposes JSON-RPC to VS Code extension + owns local file watching and DB sync +``` + +The CLI is optional future work. If added, it should be a thin wrapper over +`CSharpDB.CodeModules`, not the source of truth. + +## Project Layout + +```text +src/ + CSharpDB.CodeModules/ + CSharpDB.CodeModules.csproj + Models/ + Storage/ + Sync/ + Compilation/ + Runtime/ + Diagnostics/ + + CSharpDB.Admin/ + uses CSharpDB.CodeModules + +vscode-extension/ + src/ + extension.ts + moduleTree.ts + diagnostics.ts + sidecarClient.ts + + sidecar/ + CSharpDB.CodeModules.LanguageServer.csproj +``` + +The sidecar can live under `vscode-extension/sidecar` or `src/` depending on +packaging. It should not duplicate code-module logic. + +## Public API + +The core API should be usable by Admin, tests, VS Code sidecar, and host apps. + +```csharp +public sealed class CSharpDbCodeModuleClient +{ + public CSharpDbCodeModuleClient(ICSharpDbClient client); + + public Task> ListAsync( + CodeModuleListRequest? request = null, + CancellationToken ct = default); + + public Task GetAsync( + string moduleId, + CancellationToken ct = default); + + public Task UpsertAsync( + CodeModuleDefinition module, + CodeModuleWriteOptions? options = null, + CancellationToken ct = default); + + public Task DeleteAsync( + string moduleId, + CodeModuleDeleteOptions? options = null, + CancellationToken ct = default); + + public Task ExportAsync( + CodeModuleExportRequest request, + CancellationToken ct = default); + + public Task ImportAsync( + CodeModuleImportRequest request, + CancellationToken ct = default); + + public Task BuildAsync( + CodeModuleBuildRequest request, + CancellationToken ct = default); + + public Task TrustAsync( + CodeModuleTrustRequest request, + CancellationToken ct = default); +} +``` + +### Key Models + +```csharp +public sealed record CodeModuleDefinition( + string ModuleId, + string Name, + CodeModuleKind Kind, + string Language, + string Source, + CodeModuleScope Scope, + IReadOnlyDictionary Metadata, + string? SourceHash = null, + string? LastBuiltHash = null, + CodeModuleTrustState TrustState = CodeModuleTrustState.Untrusted, + IReadOnlyList? EventBindings = null); + +public enum CodeModuleKind +{ + Standard = 0, + Form = 1, + Report = 2, + Shared = 3, +} + +public sealed record CodeModuleEventBinding( + string OwnerKind, + string OwnerId, + string EventName, + string ProcedureName, + string ModuleId); +``` + +`Language` should be `"csharp"` in V1. The field exists so the wire shape can +evolve later. + +## Database Storage + +V1 can use system tables instead of embedding code directly inside form JSON. +Forms can reference module event procedures by id/name. + +Suggested tables: + +```sql +CREATE TABLE IF NOT EXISTS _admin_code_modules ( + module_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + language TEXT NOT NULL, + source TEXT NOT NULL, + source_hash TEXT NOT NULL, + last_built_hash TEXT, + trust_state TEXT NOT NULL, + metadata_json TEXT, + created_utc TEXT NOT NULL, + updated_utc TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS _admin_code_module_bindings ( + binding_id TEXT PRIMARY KEY, + module_id TEXT NOT NULL, + owner_kind TEXT NOT NULL, + owner_id TEXT NOT NULL, + event_name TEXT NOT NULL, + procedure_name TEXT NOT NULL, + metadata_json TEXT, + FOREIGN KEY (module_id) REFERENCES _admin_code_modules(module_id) +); + +CREATE TABLE IF NOT EXISTS _admin_code_module_builds ( + build_id TEXT PRIMARY KEY, + module_set_hash TEXT NOT NULL, + status TEXT NOT NULL, + diagnostics_json TEXT, + artifact_hash TEXT, + built_utc TEXT NOT NULL +); +``` + +Open question: whether compiled assemblies should be persisted in the database. +For V1, prefer not storing binaries until the runtime model is settled. Store +source, diagnostics, and hashes first. Compile on host startup or on first use. + +## Exported Workspace Shape + +VS Code should edit normal files generated from database modules. + +```text +.csharpdb-code/ + csharpdb.codeproj.json + forms/ + Customers/ + CustomersForm.cs + modules/ + InventoryRules.cs + SharedValidation.cs + obj/ + generated/ + CSharpDbCodeModuleRuntime.g.cs + FormContracts.g.cs + .vscode/ + tasks.json + launch.json +``` + +Manifest example: + +```json +{ + "database": "C:\\data\\app.db", + "formatVersion": 1, + "modules": [ + { + "moduleId": "forms.customers", + "path": "forms/Customers/CustomersForm.cs", + "sourceHash": "sha256:...", + "kind": "Form", + "ownerKind": "Form", + "ownerId": "customers-form" + } + ] +} +``` + +The manifest is required for conflict detection and stable module ids. + +## C# Module Shape + +Generated form module example: + +```csharp +using CSharpDB.CodeModules.Runtime; + +namespace CSharpDB.DatabaseCode.Forms; + +public sealed class CustomersFormModule : FormCodeModule +{ + public void Form_BeforeUpdate(FormBeforeUpdateContext context) + { + if (Me.Status == "Closed" && Me.ClosedDate is null) + { + context.Cancel = true; + context.Message = "Closed date is required."; + } + } + + public void btnShip_Click(FormControlEventContext context) + { + Me.Status = "Shipped"; + DoCmd.SaveRecord(); + } +} +``` + +This is C#, but with generated helper APIs that feel like Access: + +- `Me` +- `Controls` +- strongly typed field properties when schema is known +- `DoCmd` +- event context objects +- validation/cancel semantics + +## Runtime Contracts + +`CSharpDB.CodeModules.Runtime` should define small, controlled contracts: + +```csharp +public abstract class FormCodeModule +{ + protected dynamic Me { get; } + protected IFormCommandApi DoCmd { get; } + protected IFormControlApi Controls { get; } +} + +public sealed class FormBeforeUpdateContext +{ + public bool Cancel { get; set; } + public string? Message { get; set; } +} + +public interface IFormCommandApi +{ + void SaveRecord(); + void NewRecord(); + void Refresh(); + void OpenForm(string formName, object? filter = null); + void RunAction(string actionName); + ValueTask RunCommandAsync(string commandName, object? args = null); +} +``` + +The runtime API should expose form-safe operations only. Host integrations still +go through trusted host callbacks. + +## Compilation + +Use Roslyn in `CSharpDB.CodeModules.Compilation`. + +Build inputs: + +- module source files +- generated runtime contracts +- generated form schema wrappers +- allowed framework references +- CSharpDB runtime contract assembly references + +Build outputs: + +- success/failure +- diagnostics with file/module/line/column +- module set hash +- optional in-memory assembly +- optional debug harness project + +Diagnostics model: + +```csharp +public sealed record CodeModuleDiagnostic( + string ModuleId, + string? FilePath, + CodeModuleDiagnosticSeverity Severity, + string Code, + string Message, + int StartLine, + int StartColumn, + int EndLine, + int EndColumn); +``` + +VS Code maps these to the Problems panel. Admin shows them in the module detail +view. + +## Trust And Security + +Database-owned C# is powerful. V1 must fail closed. + +Trust states: + +| State | Meaning | +| --- | --- | +| `Untrusted` | Source exists but cannot execute. | +| `TrustedForThisMachine` | User explicitly trusted this DB/module set locally. | +| `TrustedBySignature` | Future signed module/package trust. | +| `Revoked` | Explicitly blocked. | + +Execution requirements: + +- Database must be trusted. +- Module source hash must match the trusted hash. +- Build diagnostics must have no errors. +- Runtime policy must allow module execution. +- Host must opt into code module execution. + +Admin should show clear trust prompts. It should never silently execute code +from a database file just because it contains modules. + +## VS Code Extension + +The VS Code extension should be a UI and sync shell, not the implementation +owner. + +Commands: + +- `CSharpDB: Connect Database` +- `CSharpDB: Export Code Modules` +- `CSharpDB: Import Code Modules` +- `CSharpDB: Sync Current File To Database` +- `CSharpDB: Watch Code Modules` +- `CSharpDB: Build Code Modules` +- `CSharpDB: Create Form Event Stub` +- `CSharpDB: Open Module From Database` +- `CSharpDB: Generate Debug Harness` + +Views: + +- Code Modules tree +- Forms and event procedures +- Build diagnostics +- Trust state + +The extension talks to a .NET sidecar/language server over JSON-RPC. The +sidecar references `CSharpDB.CodeModules` and uses the public API. + +## Sidecar Protocol + +The protocol should be simple JSON-RPC: + +```text +codeModules/connect +codeModules/list +codeModules/export +codeModules/import +codeModules/watch +codeModules/build +codeModules/upsert +codeModules/createEventStub +codeModules/trust +``` + +The sidecar owns: + +- loading CSharpDB assemblies +- talking to `ICSharpDbClient` +- file watching +- conflict detection +- Roslyn builds +- diagnostics translation + +## Admin UX + +Admin should not become the main editor. It should provide: + +- Code Modules tab. +- module list and details. +- source read-only or simple textarea fallback. +- build/trust status. +- diagnostics history. +- create event stub button from form designer. +- "Open in VS Code" command when extension/deep link support exists. + +Form designer integration: + +- select form/control event. +- choose "Create Code Module Handler". +- Admin generates `Form_BeforeUpdate`, `btnSave_Click`, etc. +- event binding points to module id and procedure name. +- module appears in Code Modules tab. + +## Import/Export And Conflict Handling + +Every sync uses hashes: + +- database source hash +- exported file hash +- manifest hash + +Import should detect: + +- file changed, DB unchanged: import cleanly. +- DB changed, file unchanged: update file on export. +- both changed: conflict result, no overwrite by default. + +Conflict result includes: + +- module id +- db hash +- file hash +- paths +- suggested resolution + +## Debugging Strategy + +V1 should not promise live Admin breakpoints. Instead: + +1. Generate a debug harness project. +2. Reference `CSharpDB.CodeModules.Runtime`. +3. Generate sample event contexts from form metadata. +4. Let VS Code run/debug the harness normally. + +Later live debugging can attach to Admin or a dedicated module host, but that +requires a runtime/debug adapter design. + +## Runtime Execution + +Admin Forms runtime flow: + +1. Event fires. +2. Runtime resolves event binding to module id/procedure. +3. Trust policy is checked. +4. Module set is compiled or loaded from cache. +5. Procedure is invoked with a typed context. +6. Result maps back to existing form event behavior: + - cancel save + - set error/message + - mutate current record + - dispatch allowed macro/actions + +Failures: + +- untrusted: block execution with clear message +- missing module/procedure: block event with diagnostics +- compile error: block event with diagnostics +- exception: fail event and record diagnostics + +## Relationship To Host Callbacks + +Database modules should call host services only through explicit host callbacks: + +```csharp +await DoCmd.RunCommandAsync("SendOpsDigest", new { OrderId = Me.Id }); +``` + +This keeps external side effects behind host registration and policy. + +## Testing Plan + +Core API tests: + +- create/list/get/update/delete modules +- module metadata round-trip +- event binding round-trip +- import/export manifest generation +- import conflict detection +- hash stability +- build success/failure diagnostics +- trust enforcement + +Admin tests: + +- create event stub from form/control event +- module appears in catalog +- untrusted module blocks execution +- trusted module executes +- compile errors shown in diagnostics +- form save cancellation from code module + +VS Code sidecar tests: + +- JSON-RPC list/export/import/build +- file watcher sync +- diagnostics mapping +- conflict reporting + +Runtime tests: + +- form before update cancel +- button click mutation +- missing procedure +- compile failure +- thrown exception +- host callback bridge through `DoCmd.RunCommandAsync` + +## Phased Implementation + +### Phase 1: Core Storage And API + +- Add `CSharpDB.CodeModules`. +- Add module models. +- Add system-table storage through `ICSharpDbClient`. +- Add list/get/upsert/delete. +- Add tests. + +### Phase 2: Import/Export Sync + +- Add file layout and manifest. +- Add export/import APIs. +- Add conflict detection. +- Add tests with temp directories. + +### Phase 3: Roslyn Build Diagnostics + +- Add C# compile service. +- Add generated runtime references. +- Return diagnostics. +- Store last build status/hash. + +### Phase 4: Admin Catalog And Stub Generation + +- Add Code Modules tab. +- Add module detail/status. +- Add form/control event stub generation. +- Add binding metadata. + +### Phase 5: VS Code Sidecar And Extension + +- Add sidecar JSON-RPC process. +- Add VS Code module tree. +- Add export/import/watch/build commands. +- Map diagnostics to Problems panel. + +### Phase 6: Trusted Runtime Execution + +- Add trust state and prompts. +- Add form event execution path. +- Add runtime contexts. +- Add diagnostics. + +### Phase 7: Debug Harness + +- Generate harness project. +- Add VS Code launch/task config. +- Add sample event contexts. + +## Open Questions + +- Store compiled assemblies in DB or cache only on disk? +- Exact trust UX and where local trust is stored. +- Whether form modules should be partial classes generated around user code. +- How strongly typed `Me` should be in V1. +- Whether remote databases can execute modules or only edit/sync them. +- How module runtime policy composes with existing callback policy. +- Whether code modules can reference other modules in V1. +- How to version module runtime contracts. + +## Recommendation + +Proceed with `CSharpDB.CodeModules` as the core. Do not build an Admin Monaco +editor for V1. Do not make the CLI a required layer. + +Use Admin for module discovery, trust, event stub creation, build status, and +runtime execution. Use VS Code plus a .NET sidecar for editing, sync, +diagnostics, and debug harness workflows. diff --git a/docs/trusted-csharp-functions/validation-rules.md b/docs/trusted-csharp-functions/validation-rules.md new file mode 100644 index 00000000..ce461b52 --- /dev/null +++ b/docs/trusted-csharp-functions/validation-rules.md @@ -0,0 +1,233 @@ +# Trusted Validation Rules + +Admin Forms can run host-registered validation callbacks before a record is +saved. The form stores only the validation rule name, fallback message, and JSON +parameters. The C# callback body is compiled into the host application and +registered during startup. + +Validation rules are trusted in-process callbacks. They are intended for +business checks that do not belong in generic field metadata, such as +cross-field validation, tenant-specific policies, or checks against host-owned +services. + +## Register Rules + +Register rules with `AddCSharpDbAdminFormValidationRules(...)`: + +```csharp +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; + +builder.Services.AddCSharpDbAdminFormValidationRules(rules => +{ + rules.AddRule( + "CustomerNamePolicy", + new DbValidationRuleOptions( + Description: "Rejects placeholder customer names.", + Timeout: TimeSpan.FromSeconds(2)), + static (context, ct) => + { + string text = context.Value.IsNull ? string.Empty : context.Value.AsText; + string blockedText = context.Parameters.TryGetValue("blockedText", out DbValue configured) + && !configured.IsNull + ? configured.AsText + : "test"; + + DbValidationRuleResult result = text.Contains(blockedText, StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Failure( + context.FallbackMessage ?? "Customer name is not allowed.", + context.FieldName, + context.RuleName) + : DbValidationRuleResult.Success(); + return ValueTask.FromResult(result); + }); +}); +``` + +Rule names are case-insensitive identifiers. Duplicate names fail during +registration so the host fails fast at startup. + +## Field-Level Rules + +Field-level rules are attached to a bound control through +`ValidationOverride.AddRules`. The runtime context includes the current field +value, field name, control id, full record, parameters, and metadata. + +```csharp +new ControlDefinition( + "customer-name", + "text", + new Rect(24, 72, 240, 32), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride( + DisableInferredRules: false, + AddRules: + [ + new ValidationRule( + "CustomerNamePolicy", + "Use the real customer name, not a placeholder.", + new Dictionary + { + ["blockedText"] = "test", + }), + ], + DisableRuleIds: [])); +``` + +The fallback message is used when the callback returns a failure without a more +specific message. Parameters are converted to `DbValue` and are available through +`context.Parameters`. + +## Form-Level Rules + +Form-level rules live on `FormDefinition.ValidationRules`. Use them for +cross-field checks and global save policies. A form-level callback can return +field-specific failures or global failures. A failure with `FieldName = null` or +an empty field name is shown as a form-level error. + +```csharp +builder.Services.AddCSharpDbAdminFormValidationRules(rules => +{ + rules.AddRule( + "CustomerReadyForInsert", + static context => + { + string status = context.Record.TryGetValue("Status", out DbValue value) + && !value.IsNull + ? value.AsText + : string.Empty; + + return string.Equals(status, "Ready", StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Success() + : DbValidationRuleResult.Failure( + [ + new DbValidationFailure( + "Status", + "Customer status must be Ready before save.", + context.RuleName), + ], + "Customer record is not ready."); + }); +}); +``` + +Attach the rule to the form: + +```csharp +var form = existingForm with +{ + ValidationRules = + [ + new ValidationRule( + "CustomerReadyForInsert", + "Customer status must be Ready before save.", + new Dictionary()), + ], +}; +``` + +## Runtime Context + +Every rule receives `DbValidationRuleContext`: + +| Property | Meaning | +| --- | --- | +| `RuleName` | The registered callback name. | +| `Scope` | `Field` or `Form`. | +| `Record` | Full current record as `IReadOnlyDictionary`. | +| `Parameters` | JSON parameters from form metadata as `DbValue`s. | +| `Metadata` | Surface, owner, location, correlation id, and form details. | +| `FormId`, `FormName`, `TableName` | Current form source metadata. | +| `ControlId`, `FieldName`, `Value` | Field-level context; null/default for form-level rules. | +| `FallbackMessage` | Designer-provided fallback message. | + +Validation callbacks are asynchronous: + +```csharp +public delegate ValueTask DbValidationRuleDelegate( + DbValidationRuleContext context, + CancellationToken ct); +``` + +Pass the cancellation token to host I/O. If a rule uses host services, capture +thread-safe services in the registration closure or register the rule from a +host-owned composition root. + +## Policy + +Validation rules request the `DbExtensionCapability.ValidationRules` +capability. `DbExtensionPolicies.DefaultHostCallbackPolicy` grants validation +rules by default. If the host uses a custom policy, it must grant that +capability: + +```csharp +builder.Services.AddSingleton(new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.ValidationRules, + DbExtensionCapabilityGrantStatus.Granted, + Exports: ["CustomerNamePolicy", "CustomerReadyForInsert"]), + ], + DefaultTimeout: TimeSpan.FromSeconds(5), + RequireSignature: true, + AllowedHostModes: DbExtensionHostMode.Embedded)); +``` + +Scoped grants can use `Exports`, `Tables`, and `Scope`. Deny grants take +precedence over allows when both match a callback request. + +## Failure Behavior + +Validation rules fail closed. Save is blocked when a rule is: + +- not registered +- denied by policy +- timed out +- canceled by the validation runtime +- throwing an exception +- returning a failed result + +The Admin callbacks tab shows registered validation rules, saved references, +policy decisions, and diagnostics history. Missing references mean saved form +metadata names a rule that the current host has not registered. + +## Generated Stubs + +Forms export automation metadata for validation rule references. The callback +catalog can generate starter registrations: + +```csharp +public static void Register( + DbFunctionRegistryBuilder functions, + DbCommandRegistryBuilder commands, + DbValidationRuleRegistryBuilder validationRules) +{ + validationRules.AddRule( + "CustomerNamePolicy", + new DbValidationRuleOptions( + Description: "TODO: describe validation rule."), + static async (context, ct) => + { + await ValueTask.CompletedTask; + return DbValidationRuleResult.Success(); + }); +} +``` + +The generated code is a handoff artifact. Keep the rule implementation in the +host project, not in form JSON or database metadata. + +## Runnable Sample + +The trusted host sample registers scalar functions, commands, and validation +rules: + +```powershell +dotnet run --project samples\trusted-csharp-host\TrustedCSharpHostSample.csproj +``` + +The sample exports form automation metadata, validates that the referenced +callbacks are registered, prints generated stubs, and runs a validation demo. diff --git a/docs/tutorials/fulfillment-ops-admin-automation.md b/docs/tutorials/fulfillment-ops-admin-automation.md new file mode 100644 index 00000000..39ee9886 --- /dev/null +++ b/docs/tutorials/fulfillment-ops-admin-automation.md @@ -0,0 +1,837 @@ +# Fulfillment Ops Admin Automation Tutorial + +This tutorial walks through a realistic CSharpDB Admin scenario for an app +builder. You will use the fulfillment demo database to inspect operational +tables, work with JSON document collections, review Access-style forms and +reports, run stored procedures, build a small form automation workflow, and +inspect the trusted host callbacks that make executable behavior visible +without storing C# code in the database. + +The goal is not just to click through Admin. The goal is to understand the +product model: + +- tables and collections hold operational data +- stored procedures package reusable SQL work inside the database +- forms and reports provide Access-style application surfaces +- macro actions are saved as database metadata +- trusted callbacks are C# code owned and registered by the host application +- callback readiness tells app builders whether saved metadata can run in the + current host + +## Scenario + +You are building an internal fulfillment console for an operations team. The +team needs to: + +- review open orders +- create or review shipments +- receive purchase orders +- process returns +- inspect scanner-session JSON captured by warehouse devices +- inspect webhook payloads received from external systems +- run repeatable SQL workflows such as allocation, receiving, and status + snapshots +- run a few host-owned automation hooks from buttons and form events +- verify that every callback referenced by forms, reports, or automation is + registered by the current Admin host + +CSharpDB Admin fits this scenario because it combines database browsing, +document inspection, form design, reports, declarative macro actions, and +stored procedure execution with trusted C# callback visibility in one tool. + +## Prerequisites + +Use a copy of the demo database for tutorial work. The tutorial includes create, +edit, and delete steps for disposable objects. + +From the repository root: + +```powershell +Copy-Item ` + -LiteralPath .\src\CSharpDB.Admin\fulfillment-hub-demo.db ` + -Destination .\src\CSharpDB.Admin\fulfillment-hub-tutorial.db ` + -Force +``` + +Start Admin against the copied database: + +```powershell +$env:ConnectionStrings__CSharpDB = 'Data Source=fulfillment-hub-tutorial.db' +dotnet run --project .\src\CSharpDB.Admin\CSharpDB.Admin.csproj --urls http://localhost:62818 +``` + +Open: + +```text +http://localhost:62818/ +``` + +The Admin header should show a connected database. If it still shows +`fulfillment-hub-demo.db`, stop Admin and restart it with the environment +variable above. + +Important trust boundary: + +- trusted callbacks are ordinary in-process C# code registered by the host +- database metadata can reference callback names, but does not store callback + implementations +- CSharpDB does not use WASM, source scanning, or in-process database-owned + plugin assemblies for this workflow + +## Tour The Admin App + +Start in the Object Explorer on the left. + +You should see these high-level groups: + +- **User Tables** +- **Collections** +- **Forms** +- **Reports** +- **Procedures** +- **Callbacks** + +Use the filter chips near the top of Object Explorer to focus the list. The +tutorial uses these objects: + +| Area | Objects | +| --- | --- | +| Tables | `orders`, `shipments`, `returns`, `products`, `inventory_positions`, `purchase_orders` | +| Collections | `scanner_sessions`, `webhook_archive` | +| Forms | `Order Workbench`, `Purchase Order Receiving`, `Return Intake` | +| Reports | `Low Stock Watch`, `Open Order Queue`, `Shipment Manifest` | +| Procedures | `AllocateOrder`, `CreateShipment`, `ReceivePurchaseOrder`, `RecordReturn`, `RefreshOperationalStats` | +| Callbacks | `Slugify`, `EchoAutomationEvent` | + +If Object Explorer does not show a new object after a write, click the refresh +button in the Object Explorer header. + +## Browse Operational Tables + +1. Click **Tables** in Object Explorer. +2. Open `orders`. +3. Page through the order rows. +4. Open `shipments`. +5. Open `returns`. +6. Open `inventory_positions`. + +Notice the table browser pattern: + +- each table opens in its own tab +- the toolbar contains refresh and paging controls +- the grid is for row browsing +- the detail or edit area is for record-level work + +This is the relational side of the fulfillment app. The next sections use +forms, procedures, collections, reports, and callbacks to build an application +workflow on top of these tables. + +## Use The Existing Forms + +1. Click **Forms** in Object Explorer. +2. Open `Order Workbench`. +3. Use the data-entry runtime to move through order records. +4. Open `Purchase Order Receiving`. +5. Open `Return Intake`. + +These forms are the Access-style app surfaces. An app builder can design forms +over tables and views, then attach event bindings and action sequences. + +Use the designer entry for `Order Workbench` when you want to edit layout or +actions. Use the data-entry entry when you want to run the form as an operator +would. + +## Use Stored Procedures For Reusable Database Work + +Stored procedures are database-owned SQL workflows. They are the right tool +when the behavior is still database work: + +| Use a stored procedure when... | Use something else when... | +| --- | --- | +| the logic is a reusable set of SQL statements | the user is running one ad hoc query | +| multiple table updates should succeed or fail together | the workflow needs external APIs, files, email, queues, or services | +| the operation needs named parameters and defaults | the workflow only changes form UI state | +| the operation should return follow-up result sets after it writes | the behavior must be host-owned C# | + +In this model: + +- procedures store SQL and parameter metadata in the database +- procedure execution runs inside one transaction +- `@parameter` references in the SQL body are bound from Args JSON or `EXEC` + arguments +- procedures can read and write tables, write audit/event rows, and return one + or more result sets +- procedures do not store C# source and are not host callbacks + +### Inspect And Run A Read-Only Procedure + +1. Click **Procedures** in Object Explorer. +2. Open `RefreshOperationalStats`. +3. Review the body SQL. It returns table stats, shortage-watch rows, and the + open order board. +4. Confirm the procedure has no parameters. +5. In **Args JSON**, keep: + + ```json + {} + ``` + +6. Click **Run**. + +Expected result: + +- the execution summary reports success +- the tab lists each statement in the procedure body +- result grids appear for the `SELECT` statements + +You can also run the same procedure from the SQL editor: + +```sql +EXEC RefreshOperationalStats; +``` + +### Inspect Write Procedures Before Running Them + +Open these procedures and read their body SQL and parameter lists: + +- `AllocateOrder` +- `ReceivePurchaseOrder` +- `CreateShipment` +- `RecordReturn` + +These are good stored-procedure candidates because they coordinate several +related table changes and then return review data. For example, `AllocateOrder` +updates inventory reservations, updates order-line allocation state, updates +the order, writes an `ops_events` row, and returns follow-up rows for review. + +Run write procedures only against the copied tutorial database. They are meant +to demonstrate operational workflows, but they still mutate real rows in that +copy. + +### Create A Tutorial Read-Only Procedure + +Create one disposable procedure so you can practice the editor without changing +operational data. + +1. Right-click **Procedures** and choose **New Procedure...**, or use the + command palette item **New Procedure**. +2. Set **Name** to: + + ```text + tutorial_OpenOrderSnapshot + ``` + +3. Set **Description** to: + + ```text + Tutorial read-only snapshot of order board rows by status. + ``` + +4. Leave **Enabled** checked. +5. Set **Body SQL** to: + + ```sql + SELECT order_number, + customer_name, + warehouse_code, + order_status, + priority_code, + total_amount + FROM order_fulfillment_board + WHERE order_status = @status + ORDER BY required_ship_date, priority_code DESC, order_number; + ``` + +6. Add one parameter: + + | Name | Type | Required | Default | Description | + | --- | --- | --- | --- | --- | + | `status` | `TEXT` | unchecked | `released` | Order status to show. | + +7. Click **Save**. +8. In **Args JSON**, enter: + + ```json + { + "status": "released" + } + ``` + +9. Click **Run** and review the result grid. + +The same procedure can be run from the SQL editor with either JSON args: + +```sql +EXEC tutorial_OpenOrderSnapshot { "status": "allocated" }; +``` + +or SQL-style args: + +```sql +EXEC tutorial_OpenOrderSnapshot @status = 'allocated'; +``` + +### When Procedures Meet Forms + +Forms can reference stored procedures through the `RunProcedure` macro action. +Use that when a button or form event should invoke a reviewed database workflow, +such as allocation, receiving, or a read-only snapshot. The action target is the +procedure name, and the action arguments become procedure arguments. + +Use `RunCommand` instead when the workflow must leave the database and call +host-owned C# services. A common pattern is: + +1. `RunProcedure` performs the database work. +2. `RunCommand` tells host-owned C# to send a notification, publish a message, + or call an external service. + +The default Admin rendered form host may keep `RunProcedure` disabled unless +the host explicitly opts in. The procedure editor and SQL editor can still run +procedures directly. + +## Use Collections For Operational JSON + +Collections are for JSON documents that are better kept as document payloads +than flattened relational rows. In this demo they represent warehouse scanner +sessions and webhook payload archives. + +### Inspect `scanner_sessions` + +1. Click **Collections** in Object Explorer. +2. Open `scanner_sessions`. +3. Confirm the collection tab shows a paged document grid. +4. Select a document. +5. Review the indented JSON in the detail panel. +6. Change the page size to `10`, `25`, `50`, or `100`. +7. Use the exact-key lookup if you know a document key. + +The demo data may fit on a single page. The important behavior is that the tab +uses the same paged browsing model for two documents as it does for thousands. + +The grid shows: + +- row number +- document key +- JSON kind +- compact preview + +The detail panel shows the selected document as formatted JSON. + +### Create And Delete A Disposable Document + +Use a tutorial-only key so cleanup is obvious. + +1. In `scanner_sessions`, click **New Document**. +2. Enter this key: + + ```text + tutorial_scanner_session + ``` + +3. Enter this JSON: + + ```json + { + "sessionId": "tutorial_scanner_session", + "warehouse": "SEA-01", + "operator": "tutorial", + "startedAt": "2026-05-01T09:00:00Z", + "events": [ + { + "kind": "scan", + "sku": "TUTORIAL-SKU", + "quantity": 1 + } + ], + "status": "review" + } + ``` + +4. Confirm **Save** is enabled only while the JSON is valid. +5. Click **Save**. +6. Select the saved document and confirm the preview updates. +7. Click **Delete**. +8. Confirm the delete prompt. + +If you intentionally break the JSON, for example by removing a closing brace, +Admin keeps **Save** disabled and shows a validation message. + +### Inspect `webhook_archive` + +1. Open `webhook_archive`. +2. Select a document. +3. Review the payload structure. +4. Review the page controls and page-size selector. If your copied database has + more webhook documents than one page can hold, move between pages. + +This is the document side of the fulfillment app. The app builder can inspect +payloads without needing a new transport contract or a custom JSON viewer. + +## Build An Access-Style Workflow + +This section adds a tutorial-only workflow to `Order Workbench`. The workflow +demonstrates the macro/action model. It stores action metadata in the form; it +does not store executable C# code in the database. + +Use a copied tutorial database before making these edits. + +### Add Tutorial Controls + +1. Open the `Order Workbench` designer. +2. Add a label near the top of the form. +3. Set the label text to: + + ```text + Tutorial review mode is active. + ``` + +4. Copy the generated read-only control ID from the property inspector and + write it down as ``. +5. Add a command button. +6. Set the button text to: + + ```text + Tutorial Review + ``` + +7. Copy its generated control ID as ``. +8. Add a second command button. +9. Set the button text to: + + ```text + Clear Tutorial Filter + ``` + +10. Copy its generated control ID as ``. + +The current designer generates control IDs and shows them as read-only. Use the +recorded IDs anywhere this tutorial asks for a target control. These controls +are tutorial-owned because their visible text and action sequence names are +tutorial-specific, so the cleanup step is still easy. + +### Add The Review Action Sequence + +Select the **Tutorial Review** button. Add an `OnClick` event, then add an +action sequence named: + +```text +tutorial_prepare_review +``` + +Add these steps in order: + +| Step | Action | Target | Value or settings | +| ---: | --- | --- | --- | +| 1 | `SetControlVisibility` | `` | `true` | +| 2 | `SetControlEnabled` | `` | `true` | +| 3 | `SetControlReadOnly` | `` | `true` | +| 4 | `ApplyFilter` | `form` | `Status <> 'Closed'` | +| 5 | `OpenForm` | `Purchase Order Receiving` | arguments JSON shown below | +| 6 | `RunCommand` | empty | command name: `EchoAutomationEvent`; arguments JSON shown below | +| 7 | `ShowMessage` | empty | `Tutorial review mode is active.` | + +For step 5, use JSON in the arguments field: + +```json +{ + "mode": "browse", + "filter": "Status <> 'Closed'" +} +``` + +For step 6, use JSON in the arguments field: + +```json +{ + "source": "tutorial_review_button" +} +``` + +Save the form. + +Run the data-entry form and click **Tutorial Review**. + +Expected result: + +- the banner becomes visible +- the clear-filter button becomes enabled +- the review button becomes read-only or disabled for editing where supported +- the current form applies the `Status <> 'Closed'` filter +- `Purchase Order Receiving` opens in a new tab with the supplied mode/filter +- `EchoAutomationEvent` runs as a trusted host command +- the form shows the tutorial message + +If the filter fails, check that the form's source has a `Status` field. If your +copy of the form uses a different field name, change the filter to a field that +exists on the form source. + +### Add The Clear Filter Sequence + +Select the **Clear Tutorial Filter** button. Add an `OnClick` event, then add +an action sequence named: + +```text +tutorial_clear_review +``` + +Add these steps: + +| Step | Action | Target | Value or settings | +| ---: | --- | --- | --- | +| 1 | `ClearFilter` | `form` | empty | +| 2 | `SetControlVisibility` | `` | `false` | +| 3 | `SetControlReadOnly` | `` | `false` | +| 4 | `ShowMessage` | empty | `Tutorial review mode cleared.` | + +Save the form, run data entry, and click **Clear Tutorial Filter**. + +Expected result: + +- the form filter is cleared +- the banner is hidden +- the review button becomes editable again where supported +- a confirmation message appears + +### Optional Power Actions: SQL And Procedures + +The action model includes `RunSql` and `RunProcedure`. The default Admin +rendered form host may leave those actions disabled by host policy, even for +read-only procedure bodies. That is intentional: SQL/procedure actions can +change data, so a host should enable them deliberately. + +To understand the design-time shape, add a disabled or non-running tutorial +sequence named: + +```text +tutorial_power_actions_reference +``` + +Add these steps with `StopOnFailure = false`: + +| Step | Action | Target | Value or settings | +| ---: | --- | --- | --- | +| 1 | `RunSql` | empty | `SELECT COUNT(*) AS open_orders FROM orders WHERE Status <> 'Closed'` | +| 2 | `RunProcedure` | `tutorial_OpenOrderSnapshot` | arguments JSON shown below | +| 3 | `ShowMessage` | empty | `Power action reference completed.` | + +For step 2, use JSON in the arguments field: + +```json +{ + "status": "released" +} +``` + +If you run this sequence in a host where SQL/procedure actions are disabled, +the expected result is a clear failure message such as `RunSql action is +disabled by host policy` or `RunProcedure action is disabled by host policy`. +That is the correct default posture. In a host that enables these actions, use +a copied tutorial database and only run idempotent or disposable operations. + +If `tutorial_OpenOrderSnapshot` was not created earlier, use +`RefreshOperationalStats` instead and leave the arguments field as `{}`. + +## Trusted Host Callbacks + +Callbacks are the bridge between saved database metadata and host-owned C#. + +1. Click **Callbacks** in Object Explorer. +2. Open the callback catalog. +3. Select `Slugify`. +4. Review its descriptor: + - kind: scalar function + - runtime: `HostCallback` + - return type: text + - deterministic/null behavior + - capability requests and policy decision +5. Select `EchoAutomationEvent`. +6. Review its descriptor: + - kind: command + - runtime: `HostCallback` + - command capability + - policy decision + +The readiness badge summarizes whether saved metadata references callbacks that +are missing from the current host. + +Important: + +- `Slugify` and `EchoAutomationEvent` are registered by the Admin host at + startup +- the database can reference these names +- the database does not contain their C# implementation +- descriptor and policy metadata make callbacks visible, but do not sandbox + them + +## Demonstrate Missing Callback Readiness + +Use the copied tutorial database for this step. + +1. Open the `Order Workbench` designer. +2. Select the **Tutorial Review** button. +3. Add one more `RunCommand` step to `tutorial_prepare_review`. +4. Set the command name to: + + ```text + tutorial_SendOpsDigest + ``` + +5. Set `StopOnFailure = false`. +6. Add arguments: + + ```json + { + "source": "tutorial_missing_callback_demo" + } + ``` + +7. Save the form. +8. Open **Callbacks**. +9. Click **Refresh**. + +Expected result: + +- the readiness badge reports a missing callback +- the grid includes `tutorial_SendOpsDigest` +- the row is marked missing +- the details panel shows the form/reference location +- **Stubs** is enabled + +Click **Stubs** to copy registration stub source for all missing callbacks, or +select the missing row and click **Copy Stub** for only that callback. + +The copied stub is a developer handoff. It is not stored in the database and it +is not automatically trusted. A host developer must implement and register the +command in the host application. + +Cleanup options: + +- remove the `tutorial_SendOpsDigest` step from the form, or +- keep it as a deliberate missing-callback example in the copied tutorial + database + +## Implement The Missing Callback In A Host + +The previous section created the app-builder side of the story: database +metadata now references a command named `tutorial_SendOpsDigest`. The database +still does not contain C# code. A host developer must add that command to the +host application and register it at startup. + +For the Admin demo host, the registration point is: + +```text +src/CSharpDB.Admin/Services/AdminHostCallbacks.cs +``` + +For a smaller VS Code debugging walkthrough, open the sample host project: + +```powershell +code .\samples\trusted-csharp-host +``` + +That sample shows the same pattern with `Slugify` and +`AuditCustomerChange`: callbacks are ordinary C# methods/delegates, registered +with `DbFunctionRegistry` or `DbCommandRegistry`, then invoked by SQL or form +automation metadata that references them by name. + +A host developer would implement the tutorial command like this inside the +host's command registry setup: + +```csharp +commands.AddCommand( + "tutorial_SendOpsDigest", + new DbCommandOptions("Sends a tutorial fulfillment operations digest."), + static context => + { + string source = context.Arguments.TryGetValue("source", out DbValue value) + ? value.AsText + : "unknown"; + + // Call host-owned services here: email, queues, logging, APIs, etc. + return DbCommandResult.Success($"Tutorial digest requested by {source}."); + }); +``` + +Debug flow: + +1. Put a breakpoint inside the command delegate. +2. Start the host from VS Code with `F5`. +3. Open Admin against the copied tutorial database. +4. Run the form action that references `tutorial_SendOpsDigest`. +5. Confirm the breakpoint is hit in host-owned C# code. +6. Refresh **Callbacks** and confirm readiness changes from missing to + registered/allowed when the name and kind match. + +The generated stub from Admin is only a starting point for the developer. The +trusted implementation is the reviewed C# code compiled into the host app. + +## Reports And Review + +Reports let app builders validate that the workflow supports operational +review, not only data entry. + +1. Click **Reports** in Object Explorer. +2. Open `Low Stock Watch`. +3. Preview the report. +4. Open `Open Order Queue`. +5. Preview the report. +6. Open `Shipment Manifest`. +7. Preview the report. + +Use these reports to answer: + +- do order changes show up where operators expect? +- does low-stock review still surface the right products? +- can a shipment manifest be reviewed after order/shipment work? +- do report expressions and data sources still load after form automation + changes? + +If a report uses host-registered scalar functions in calculated expressions, +those functions appear in the same callback catalog as form commands. + +## Security Model + +This tutorial uses several different kinds of behavior: + +| Behavior | Stored in database? | Executes C#? | Trust model | +| --- | --- | --- | --- | +| Tables and rows | yes | no | data only | +| Collection documents | yes | no | JSON data only | +| Stored procedures | yes | no | declarative SQL executed by CSharpDB | +| Form/report metadata | yes | no | declarative metadata | +| Macro/action sequences | yes | no | interpreted by Admin/runtime | +| Trusted callbacks | name/reference only | yes | host-owned in-process C# | + +CSharpDB deliberately does not treat database files as executable plugin +packages. + +Rejected for this feature track: + +- WASM plugin execution +- source scanning as a security boundary +- runtime-loaded database-owned assemblies in the CSharpDB process +- C# source stored in the database and compiled on normal open paths + +Future out-of-process .NET/C# workers remain a gated exploration path for +portable extension packages. They are not part of this tutorial workflow. + +## Troubleshooting + +### The Admin app opened the original demo database + +Stop Admin and restart with: + +```powershell +$env:ConnectionStrings__CSharpDB = 'Data Source=fulfillment-hub-tutorial.db' +dotnet run --project .\src\CSharpDB.Admin\CSharpDB.Admin.csproj --urls http://localhost:62818 +``` + +### Object Explorer does not show the object I expect + +Click the refresh icon in Object Explorer. If the object is still missing, +check that the tutorial database copy is the active database. + +### A collection document will not save + +Check: + +- the key is not blank +- the JSON is valid +- the collection name is `scanner_sessions` or another existing collection +- you are not editing a read-only existing key + +### A form filter fails + +Check: + +- the filter references a field that exists on the form source +- text values are quoted +- brackets are balanced +- parameters such as `@status` have matching action arguments + +Examples: + +```text +Status <> 'Closed' +[Status] = 'Ready' +Quantity > 0 +ClosedAt = null +``` + +### `RunSql` or `RunProcedure` is disabled + +That is expected in the default safe host posture unless the host explicitly +enables those rendered form actions. Use `RunCommand` for trusted host-owned C# +callbacks, or enable SQL/procedure actions only in a host that has reviewed the +risk. + +### A procedure will not save or run + +Check: + +- the procedure name is a simple identifier +- every `@parameter` referenced by Body SQL is listed in Parameters +- Args JSON contains every required parameter +- argument names match parameter names without the `@` prefix +- text values are JSON strings in Args JSON or single-quoted in `EXEC` +- the procedure is enabled + +Examples: + +```json +{ + "status": "released" +} +``` + +```sql +EXEC tutorial_OpenOrderSnapshot @status = 'released'; +``` + +### A callback is missing + +Open **Callbacks**, refresh the catalog, select the missing callback, and use +**Copy Stub**. Give the generated stub to the host developer who owns the +application. The fix is to register host-owned C# code with the same callback +name and compatible arity. + +### A callback is denied + +Review the capability grid and policy reason in the callback details. A denied +callback should not execute. The host policy must grant the requested capability +before the callback is considered ready. + +### The tutorial controls should be removed + +Open the `Order Workbench` designer and delete: + +- the label with text `Tutorial review mode is active.` +- the `Tutorial Review` button +- the `Clear Tutorial Filter` button +- action sequences whose names start with `tutorial_` + +Save the form and refresh Object Explorer. + +Also delete the disposable procedure: + +- `tutorial_OpenOrderSnapshot` + +## What You Built + +You used one copied fulfillment database to exercise the full current app-builder +story: + +- browsed relational operational tables +- inspected existing stored procedures and created a disposable read-only + procedure +- inspected paged JSON collections +- edited a disposable collection document +- used forms as Access-style data-entry surfaces +- added macro-style form action sequences +- used trusted host callbacks from saved metadata +- inspected callback readiness and generated missing registration stubs +- reviewed reports as operational validation surfaces +- kept executable code host-owned instead of database-owned + +That is the intended production posture for the current CSharpDB Admin +automation model. diff --git a/samples/README.md b/samples/README.md index 8d38f2d6..38cba5fe 100644 --- a/samples/README.md +++ b/samples/README.md @@ -19,6 +19,7 @@ The SQL dataset samples use a conventional layout with `schema.sql` for setup, ` | `collection-indexing/` | Runnable `Collection` indexing walkthrough | `.csproj`, `Program.cs`, `README.md` | | `generated-collections/` | Runnable source-generated collection fast-path walkthrough | `.csproj`, `Program.cs`, `README.md` | | `efcore-provider/` | Runnable EF Core 10 embedded-provider sample | `.csproj`, `Program.cs`, `README.md` | +| `trusted-csharp-host/` | Runnable trusted callback host sample | scalar functions, commands, validation rules, form automation metadata, `.csproj`, `Program.cs`, `README.md` | ## Tutorials diff --git a/samples/trusted-csharp-host/.vscode/launch.json b/samples/trusted-csharp-host/.vscode/launch.json new file mode 100644 index 00000000..c41047b9 --- /dev/null +++ b/samples/trusted-csharp-host/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Trusted C# Host Sample", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build trusted C# host sample", + "program": "${workspaceFolder}/bin/Debug/net10.0/TrustedCSharpHostSample.dll", + "args": [], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] +} diff --git a/samples/trusted-csharp-host/.vscode/tasks.json b/samples/trusted-csharp-host/.vscode/tasks.json new file mode 100644 index 00000000..5d40ff03 --- /dev/null +++ b/samples/trusted-csharp-host/.vscode/tasks.json @@ -0,0 +1,28 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build trusted C# host sample", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/TrustedCSharpHostSample.csproj" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "run trusted C# host sample", + "type": "process", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/TrustedCSharpHostSample.csproj" + ], + "problemMatcher": "$msCompile", + "group": "test" + } + ] +} diff --git a/samples/trusted-csharp-host/Program.cs b/samples/trusted-csharp-host/Program.cs new file mode 100644 index 00000000..a9876d6d --- /dev/null +++ b/samples/trusted-csharp-host/Program.cs @@ -0,0 +1,341 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Engine; +using CSharpDB.Primitives; + +Console.WriteLine("CSharpDB trusted C# host sample"); +Console.WriteLine(); + +DbFunctionRegistry functions = DbFunctionRegistry.Create(builder => +{ + builder.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => + DbValue.FromText(Slugify(args[0].AsText))); +}); + +List auditLog = []; +DbCommandRegistry commands = DbCommandRegistry.Create(builder => +{ + builder.AddCommand( + "AuditCustomerChange", + new DbCommandOptions("Records a customer workflow event."), + context => + { + long customerId = context.Arguments["Id"].AsInteger; + string status = context.Arguments["Status"].AsText; + string source = context.Arguments["source"].AsText; + string eventName = context.Metadata["event"]; + string actionSequence = context.Metadata.TryGetValue("actionSequence", out string? value) + ? value + : "(none)"; + + auditLog.Add( + $"Customer {customerId} -> {status}; source={source}; event={eventName}; sequence={actionSequence}"); + return DbCommandResult.Success(); + }); +}); + +DbValidationRuleRegistry validationRules = DbValidationRuleRegistry.Create(builder => +{ + builder.AddRule( + "CustomerNamePolicy", + new DbValidationRuleOptions( + Description: "Rejects placeholder customer names.", + Timeout: TimeSpan.FromSeconds(2)), + static (context, _) => + { + string text = context.Value.IsNull ? string.Empty : context.Value.AsText; + string blockedText = context.Parameters.TryGetValue("blockedText", out DbValue configured) + && !configured.IsNull + ? configured.AsText + : "test"; + + DbValidationRuleResult result = text.Contains(blockedText, StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Failure( + context.FallbackMessage ?? "Customer name is not allowed.", + context.FieldName, + context.RuleName) + : DbValidationRuleResult.Success(); + return ValueTask.FromResult(result); + }); + + builder.AddRule( + "CustomerReadyForInsert", + new DbValidationRuleOptions( + Description: "Requires the customer workflow status before save."), + static context => + { + string requiredStatus = context.Parameters.TryGetValue("requiredStatus", out DbValue configured) + && !configured.IsNull + ? configured.AsText + : "Ready"; + string status = context.Record.TryGetValue("Status", out DbValue value) && !value.IsNull + ? value.AsText + : string.Empty; + + return string.Equals(status, requiredStatus, StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Success() + : DbValidationRuleResult.Failure( + [ + new DbValidationFailure( + "Status", + context.FallbackMessage ?? $"Status must be {requiredStatus}.", + context.RuleName), + ], + "Customer record is not ready."); + }); +}); + +FormDefinition form = FormAutomationMetadata.NormalizeForExport(CreateCustomerEntryForm()); +DbAutomationMetadata automation = form.Automation + ?? throw new InvalidOperationException("The sample form should export automation metadata."); + +PrintAutomationMetadata(automation); +ValidateAutomationMetadata(automation, functions, commands, validationRules); +PrintGeneratedStub(automation); + +await RunSqlScalarFunctionDemoAsync(functions); +await RunAdminFormsAutomationDemoAsync(form, commands, auditLog); +await RunAdminFormsValidationDemoAsync(form, validationRules); + +Console.WriteLine(); +Console.WriteLine("Set breakpoints inside Slugify, AuditCustomerChange, or a validation rule, then run this sample from VS Code."); + +static async Task RunSqlScalarFunctionDemoAsync(DbFunctionRegistry functions) +{ + await using Database db = await Database.OpenInMemoryAsync(new DatabaseOptions + { + Functions = functions, + }); + + await db.ExecuteAsync(""" + CREATE TABLE articles ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT + ); + """); + + await db.ExecuteAsync("INSERT INTO articles VALUES (1, 'Hello From VS Code', Slugify('Hello From VS Code'));"); + await db.ExecuteAsync("INSERT INTO articles VALUES (2, 'Trusted CSharpDB Callbacks', Slugify('Trusted CSharpDB Callbacks'));"); + + Console.WriteLine(); + Console.WriteLine("SQL scalar function result:"); + await using var rows = await db.ExecuteAsync(""" + SELECT id, title, slug + FROM articles + ORDER BY id; + """); + + while (await rows.MoveNextAsync()) + { + IReadOnlyList row = rows.Current; + Console.WriteLine($" {row[0].AsInteger}: {row[1].AsText} -> {row[2].AsText}"); + } +} + +static async Task RunAdminFormsAutomationDemoAsync( + FormDefinition form, + DbCommandRegistry commands, + IReadOnlyList auditLog) +{ + var record = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Id"] = 42L, + ["Name"] = "Ada Lovelace", + }; + + var dispatcher = new DefaultFormEventDispatcher(commands); + FormEventDispatchResult dispatchResult = await dispatcher.DispatchAsync(form, FormEventKind.BeforeInsert, record); + + Console.WriteLine(); + Console.WriteLine("Admin Forms action sequence result:"); + Console.WriteLine($" Succeeded: {dispatchResult.Succeeded}"); + Console.WriteLine($" Status field: {record["Status"]}"); + foreach (string auditEntry in auditLog) + Console.WriteLine($" Audit: {auditEntry}"); +} + +static async Task RunAdminFormsValidationDemoAsync( + FormDefinition form, + DbValidationRuleRegistry validationRules) +{ + var record = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Id"] = 43L, + ["Name"] = "Test Account", + ["Status"] = "Draft", + }; + + var validation = new DefaultValidationInferenceService(validationRules, DbExtensionPolicies.DefaultHostCallbackPolicy); + IReadOnlyList errors = await validation.EvaluateAsync(form, record); + + Console.WriteLine(); + Console.WriteLine("Admin Forms validation result:"); + foreach (ValidationError error in errors) + Console.WriteLine($" {error.FieldName}: {error.RuleId} - {error.Message}"); +} + +static void PrintAutomationMetadata(DbAutomationMetadata automation) +{ + Console.WriteLine("Exported automation metadata:"); + foreach (DbAutomationScalarFunctionReference function in automation.ScalarFunctions ?? []) + { + Console.WriteLine( + $" scalar {function.Name}/{function.Arity} from {function.Surface}:{function.Location}"); + } + + foreach (DbAutomationCommandReference command in automation.Commands ?? []) + Console.WriteLine($" command {command.Name} from {command.Surface}:{command.Location}"); + + foreach (DbAutomationValidationRuleReference rule in automation.ValidationRules ?? []) + Console.WriteLine($" validation {rule.Name} from {rule.Surface}:{rule.Location}"); +} + +static void ValidateAutomationMetadata( + DbAutomationMetadata automation, + DbFunctionRegistry functions, + DbCommandRegistry commands, + DbValidationRuleRegistry validationRules) +{ + AutomationValidationResult result = AutomationManifestValidator.Validate( + automation, + functions, + commands, + validationRules, + new AutomationManifestValidationOptions(RequireMetadata: true)); + + Console.WriteLine(); + Console.WriteLine("Automation validation:"); + if (result.Succeeded) + { + Console.WriteLine(" All referenced callbacks are registered."); + return; + } + + foreach (AutomationValidationIssue issue in result.Issues) + Console.WriteLine($" {issue.Severity}: {issue.Message}"); +} + +static void PrintGeneratedStub(DbAutomationMetadata automation) +{ + string source = AutomationStubGenerator.GenerateCSharp( + automation, + new AutomationStubGenerationOptions( + Namespace: "MyApp.CSharpDbAutomation", + ClassName: "CSharpDbAutomationRegistration")); + + Console.WriteLine(); + Console.WriteLine("Starter C# registration stub:"); + Console.WriteLine(source); +} + +static FormDefinition CreateCustomerEntryForm() + => new( + "customers-entry", + "Customers Entry", + "Customers", + DefinitionVersion: 1, + SourceSchemaSignature: "sample:customers:v1", + Layout: new LayoutDefinition("absolute", 8, SnapToGrid: true, Breakpoints: []), + Controls: + [ + new ControlDefinition( + "slug-preview", + "computed", + new Rect(24, 24, 240, 32), + Binding: null, + Props: new PropertyBag(new Dictionary + { + ["formula"] = "=Slugify(Name)", + }), + ValidationOverride: null), + new ControlDefinition( + "customer-name", + "text", + new Rect(24, 72, 240, 32), + Binding: new BindingDefinition("Name", "TwoWay"), + Props: new PropertyBag(new Dictionary + { + ["label"] = "Name", + }), + ValidationOverride: new ValidationOverride( + DisableInferredRules: false, + AddRules: + [ + new ValidationRule( + "CustomerNamePolicy", + "Use the real customer name, not a placeholder.", + new Dictionary + { + ["blockedText"] = "test", + }), + ], + DisableRuleIds: [])), + new ControlDefinition( + "customer-status", + "text", + new Rect(24, 120, 160, 32), + Binding: new BindingDefinition("Status", "TwoWay"), + Props: new PropertyBag(new Dictionary + { + ["label"] = "Status", + }), + ValidationOverride: null), + ], + EventBindings: + [ + new FormEventBinding( + FormEventKind.BeforeInsert, + CommandName: string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.SetFieldValue, + Target: "Status", + Value: "Ready"), + new DbActionStep( + DbActionKind.RunActionSequence, + SequenceName: "ReusableCustomerAudit", + Arguments: new Dictionary + { + ["source"] = "trusted-csharp-host-sample", + }), + ], + Name: "PrepareCustomerInsert")), + ], + ActionSequences: + [ + new DbActionSequence( + [ + new DbActionStep( + DbActionKind.RunCommand, + CommandName: "AuditCustomerChange"), + ], + Name: "ReusableCustomerAudit"), + ], + ValidationRules: + [ + new ValidationRule( + "CustomerReadyForInsert", + "Customer status must be Ready before save.", + new Dictionary + { + ["requiredStatus"] = "Ready", + }), + ]); + +static string Slugify(string text) +{ + return text + .Trim() + .ToLowerInvariant() + .Replace(' ', '-'); +} diff --git a/samples/trusted-csharp-host/README.md b/samples/trusted-csharp-host/README.md new file mode 100644 index 00000000..90d7a21b --- /dev/null +++ b/samples/trusted-csharp-host/README.md @@ -0,0 +1,135 @@ +# Trusted C# Host Sample + +This sample is the VS Code workflow for CSharpDB's trusted C# integration. It +is a normal C# project: open this folder in VS Code, set breakpoints inside the +registered callbacks, and run or debug the host process. + +It demonstrates: + +- registering a trusted scalar function with `DbFunctionRegistry` +- calling that function from SQL +- registering a trusted command with `DbCommandRegistry` +- registering trusted Admin Forms validation rules with + `DbValidationRuleRegistry` +- exporting Admin Forms automation metadata +- validating automation metadata against registered callbacks +- generating starter C# registration stubs from automation metadata +- running an Admin Forms action sequence that sets a field +- invoking a reusable named Admin Forms action sequence that calls the command +- running field-level and form-level validation callbacks +- inspecting an Access-style macro form manifest with open form, filter, + run SQL, and conditional UI rule actions +- inspecting callback arguments and metadata in console output + +The sample keeps the important runtime boundary visible: C# callback bodies live +in the host project. The database/form metadata stores names and action data +only. + +## Run From VS Code + +1. Open `samples/trusted-csharp-host` in VS Code. +2. Install the C# Dev Kit or C# extension if VS Code prompts for it. +3. Press `F5`, or run the task `run trusted C# host sample`. +4. Watch the sample print exported automation metadata. +5. Watch validation confirm that referenced callbacks are registered. +6. Inspect the generated starter C# registration stub. +7. Put breakpoints in `Slugify`, `AuditCustomerChange`, or one of the + validation rule callbacks. + +## Developer Handoff Story + +The intended production workflow has two roles: + +1. An app builder creates metadata in Admin, such as a calculated expression + that calls `Slugify(...)` or a form action sequence that runs + `AuditCustomerChange`. +2. A host developer owns the C# implementation, registers that callback during + startup, and debugs it from VS Code. + +The database/form metadata stores callback names, argument values, and +reference locations. It does not store C# source or compiled assemblies. + +`Program.cs` shows both registration paths: + +```csharp +builder.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText))); +``` + +```csharp +builder.AddCommand( + "AuditCustomerChange", + new DbCommandOptions("Records a customer workflow event."), + context => + { + long customerId = context.Arguments["Id"].AsInteger; + string status = context.Arguments["Status"].AsText; + + return DbCommandResult.Success( + $"Customer {customerId} changed to {status}."); +}); +``` + +Validation rules use the same host-owned pattern: + +```csharp +builder.AddRule( + "CustomerNamePolicy", + new DbValidationRuleOptions( + Description: "Rejects placeholder customer names.", + Timeout: TimeSpan.FromSeconds(2)), + static (context, ct) => + { + string text = context.Value.IsNull ? string.Empty : context.Value.AsText; + + DbValidationRuleResult result = text.Contains("test", StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Failure( + context.FallbackMessage ?? "Customer name is not allowed.", + context.FieldName, + context.RuleName) + : DbValidationRuleResult.Success(); + return ValueTask.FromResult(result); + }); +``` + +When Admin reports a missing callback, use the generated stub as the handoff +artifact. The host developer pastes the registration shape into the host app, +replaces the stub body with reviewed C# code, sets a breakpoint, then runs the +host with `F5` to verify the metadata reference reaches the callback. + +## Run From Terminal + +```powershell +dotnet run --project samples\trusted-csharp-host\TrustedCSharpHostSample.csproj +``` + +Expected output includes: + +- exported callback metadata and locations +- validation status +- generated starter registration code +- slug values from SQL +- an audit entry from the reusable form action sequence +- validation errors from a field rule and a form rule + +The audit entry prints callback metadata such as the form event and reusable +action sequence name, along with callback arguments passed from the form record +and action sequence. + +The validation result prints the failing field, rule id, and message. The same +rules block save in Admin Forms when referenced by saved form metadata. + +## Files + +- `Program.cs` contains the host registration code, metadata validation, stub + generation, and runnable demo. +- `access-style-macro-form.json` contains a Phase 8 form manifest with richer + macro actions and conditional UI rules. +- `.vscode/launch.json` launches the sample under the debugger. +- `.vscode/tasks.json` builds and runs the sample from VS Code tasks. diff --git a/samples/trusted-csharp-host/TrustedCSharpHostSample.csproj b/samples/trusted-csharp-host/TrustedCSharpHostSample.csproj new file mode 100644 index 00000000..a8952804 --- /dev/null +++ b/samples/trusted-csharp-host/TrustedCSharpHostSample.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/samples/trusted-csharp-host/access-style-macro-form.json b/samples/trusted-csharp-host/access-style-macro-form.json new file mode 100644 index 00000000..211f6fa0 --- /dev/null +++ b/samples/trusted-csharp-host/access-style-macro-form.json @@ -0,0 +1,178 @@ +{ + "formId": "products-entry-access-macros", + "name": "Products Entry Access Macros", + "tableName": "Products", + "definitionVersion": 1, + "sourceSchemaSignature": "sample:products:v1", + "layout": { + "layoutMode": "absolute", + "gridSize": 8, + "snapToGrid": true, + "breakpoints": [] + }, + "controls": [ + { + "controlId": "statusBox", + "controlType": "text", + "rect": { "x": 24, "y": 24, "width": 180, "height": 32 }, + "binding": { "fieldName": "Status", "mode": "TwoWay" }, + "props": { "placeholder": "Status" } + }, + { + "controlId": "openOrdersButton", + "controlType": "commandButton", + "rect": { "x": 224, "y": 24, "width": 120, "height": 32 }, + "props": { "text": "Open Orders" }, + "eventBindings": [ + { + "event": "onClick", + "commandName": "", + "stopOnFailure": true, + "actionSequence": { + "name": "OpenOrders", + "steps": [ + { + "kind": "openForm", + "target": "Orders Entry", + "arguments": { + "recordId": "$record.Id", + "mode": "view" + } + } + ] + } + } + ] + }, + { + "controlId": "showOpenOrdersButton", + "controlType": "commandButton", + "rect": { "x": 24, "y": 72, "width": 152, "height": 32 }, + "props": { "text": "Show Open" }, + "eventBindings": [ + { + "event": "onClick", + "commandName": "", + "stopOnFailure": true, + "actionSequence": { + "name": "ShowOpenOrders", + "steps": [ + { + "kind": "applyFilter", + "target": "ordersGrid", + "value": "[Status] = @status", + "arguments": { + "status": "Open" + } + }, + { + "kind": "setControlVisibility", + "target": "clearOrdersFilterButton", + "value": true + } + ] + } + } + ] + }, + { + "controlId": "clearOrdersFilterButton", + "controlType": "commandButton", + "rect": { "x": 184, "y": 72, "width": 152, "height": 32 }, + "props": { + "text": "Clear Filter", + "visible": false + }, + "eventBindings": [ + { + "event": "onClick", + "commandName": "", + "stopOnFailure": true, + "actionSequence": { + "name": "ClearOrdersFilter", + "steps": [ + { + "kind": "clearFilter", + "target": "ordersGrid" + }, + { + "kind": "setControlVisibility", + "target": "clearOrdersFilterButton", + "value": false + } + ] + } + } + ] + }, + { + "controlId": "archiveButton", + "controlType": "commandButton", + "rect": { "x": 344, "y": 72, "width": 120, "height": 32 }, + "props": { "text": "Archive" }, + "eventBindings": [ + { + "event": "onClick", + "commandName": "", + "stopOnFailure": true, + "actionSequence": { + "name": "ArchiveProduct", + "steps": [ + { + "kind": "runSql", + "value": "UPDATE Products SET Status = @status WHERE Id = @id", + "arguments": { + "status": "Archived", + "id": "$record.Id", + "refresh": true + } + }, + { + "kind": "setControlProperty", + "target": "statusBox", + "value": "Archived", + "arguments": { + "property": "value" + } + } + ] + } + } + ] + }, + { + "controlId": "ordersGrid", + "controlType": "datagrid", + "rect": { "x": 24, "y": 128, "width": 720, "height": 260 }, + "props": { + "childTable": "Orders", + "dataGridMode": "related", + "foreignKeyField": "ProductId", + "parentKeyField": "Id", + "visibleColumns": [ "OrderId", "Status", "Total" ], + "allowAdd": true, + "allowEdit": true, + "allowDelete": false + } + } + ], + "rules": [ + { + "ruleId": "archived-state", + "condition": "[Status] = 'Archived'", + "description": "Archived products cannot be edited from the entry form.", + "effects": [ + { + "controlId": "statusBox", + "property": "readOnly", + "value": true + }, + { + "controlId": "archiveButton", + "property": "enabled", + "value": false + } + ] + } + ] +} diff --git a/scripts/Start-CSharpDbAdminDirect.ps1 b/scripts/Start-CSharpDbAdminDirect.ps1 index 26ecc76a..7f5b63c9 100644 --- a/scripts/Start-CSharpDbAdminDirect.ps1 +++ b/scripts/Start-CSharpDbAdminDirect.ps1 @@ -6,11 +6,12 @@ Configures the admin site for direct mode, then starts only the admin host. This script is intended for local development and manual operator workflows. It updates `src/CSharpDB.Admin/appsettings.json` to use `CSharpDbTransport.Direct`, removes any daemon endpoint from the admin config, -ensures a connection string exists, and then starts `CSharpDB.Admin`. +ensures a connection string exists, builds `CSharpDB.Admin`, and then starts it. -The script does not install a Windows service or a background task. It launches -one `dotnet run` process. If you close the shell that launched this script, the -child process continues running until you stop it explicitly. +The script does not install a Windows service or a background task. It builds +the admin project first, then launches one `dotnet run --no-build` process. If +you close the shell that launched this script, the child process continues +running until you stop it explicitly. .PARAMETER NoLaunch Only updates the admin configuration. Does not start the admin host. @@ -26,7 +27,8 @@ later with `Stop-Process`. Overrides the admin database connection string before launch. .PARAMETER AdminStartupTimeoutSeconds -How long to wait for the admin endpoint to start accepting TCP connections. +How long to wait for the admin endpoint to start accepting TCP connections +after the admin project has built and the app process has launched. .EXAMPLE powershell -ExecutionPolicy Bypass -File .\scripts\Start-CSharpDbAdminDirect.ps1 @@ -52,7 +54,7 @@ param( [switch]$OpenAdmin, [switch]$PassThru, [string]$ConnectionString, - [int]$AdminStartupTimeoutSeconds = 30 + [int]$AdminStartupTimeoutSeconds = 60 ) $ErrorActionPreference = 'Stop' @@ -182,6 +184,47 @@ function Select-PreferredUrl { return $urls[0] } +function Get-LaunchUris { + param( + [Parameter(Mandatory = $true)] + [string]$ApplicationUrl + ) + + return @( + $ApplicationUrl.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } | + ForEach-Object { [Uri]$_ } + ) +} + +function Test-TcpEndpoint { + param( + [Parameter(Mandatory = $true)] + [Uri]$Uri + ) + + $client = $null + + try { + $client = [System.Net.Sockets.TcpClient]::new() + $connectTask = $client.ConnectAsync($Uri.Host, $Uri.Port) + + if ($connectTask.Wait([TimeSpan]::FromSeconds(1)) -and $client.Connected) { + return $true + } + } + catch { + } + finally { + if ($null -ne $client) { + $client.Dispose() + } + } + + return $false +} + function Wait-ForTcpEndpoint { param( [Parameter(Mandatory = $true)] @@ -193,28 +236,45 @@ function Wait-ForTcpEndpoint { $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { - $client = $null + if (Test-TcpEndpoint -Uri $Uri) { + return $true + } + + Start-Sleep -Milliseconds 500 + } + + return $false +} + +function Wait-ForAnyTcpEndpoint { + param( + [Parameter(Mandatory = $true)] + [Uri[]]$Uris, + [Parameter(Mandatory = $true)] + [int]$TimeoutSeconds, + [System.Diagnostics.Process]$Process + ) - try { - $client = [System.Net.Sockets.TcpClient]::new() - $connectTask = $client.ConnectAsync($Uri.Host, $Uri.Port) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - if ($connectTask.Wait([TimeSpan]::FromSeconds(1)) -and $client.Connected) { - return $true + while ((Get-Date) -lt $deadline) { + if ($null -ne $Process) { + $Process.Refresh() + if ($Process.HasExited) { + return $null } } - catch { - } - finally { - if ($null -ne $client) { - $client.Dispose() + + foreach ($uri in $Uris) { + if (Test-TcpEndpoint -Uri $uri) { + return $uri } } Start-Sleep -Milliseconds 500 } - return $false + return $null } function Stop-ProcessIfRunning { @@ -242,6 +302,7 @@ $originalAdminJson = $adminConfig | ConvertTo-Json -Depth 20 $adminLaunchProfile = Get-LaunchProfile -LaunchSettings $adminLaunchSettings -PreferredProfileName 'CSharpDB.Admin' $adminUrl = Select-PreferredUrl -ApplicationUrl $adminLaunchProfile.Profile.applicationUrl -PreferredScheme 'https' +$adminUris = @(Get-LaunchUris -ApplicationUrl $adminLaunchProfile.Profile.applicationUrl) $csharpDbSection = Get-OrAddProperty -Object $adminConfig -Name 'CSharpDB' -DefaultValue ([pscustomobject]@{}) Set-JsonProperty -Object $csharpDbSection -Name 'Transport' -Value 'direct' @@ -291,19 +352,30 @@ if ($NoLaunch) { $adminArgs = @( 'run', + '--no-build', '--project', $adminProjectPath, '--launch-profile', $adminLaunchProfile.Name ) +Write-Host "Building admin project..." +dotnet build $adminProjectPath | Out-Host +if ($LASTEXITCODE -ne 0) { + throw "The admin project build failed with exit code $LASTEXITCODE." +} + Write-Host "Starting admin profile '$($adminLaunchProfile.Name)'..." $adminProcess = Start-Process -FilePath 'dotnet' -ArgumentList $adminArgs -WorkingDirectory $repoRoot -PassThru -if ($adminUrl) { - if (-not (Wait-ForTcpEndpoint -Uri ([Uri]$adminUrl) -TimeoutSeconds $AdminStartupTimeoutSeconds)) { +if ($adminUris.Count -gt 0) { + $listeningUri = Wait-ForAnyTcpEndpoint -Uris $adminUris -TimeoutSeconds $AdminStartupTimeoutSeconds -Process $adminProcess + if ($null -eq $listeningUri) { Stop-ProcessIfRunning -Process $adminProcess - throw "The admin site did not start listening on $adminUrl within $AdminStartupTimeoutSeconds seconds." + + $configuredUrls = ($adminUris | ForEach-Object { $_.ToString().TrimEnd('/') }) -join ', ' + throw "The admin site did not start listening on any configured URL ($configuredUrls) within $AdminStartupTimeoutSeconds seconds." } + $adminUrl = $listeningUri.ToString().TrimEnd('/') Write-Host "Admin is listening on $adminUrl (PID $($adminProcess.Id))." if ($OpenAdmin) { diff --git a/src/CSharpDB.Admin.Forms/CSharpDB.Admin.Forms.csproj b/src/CSharpDB.Admin.Forms/CSharpDB.Admin.Forms.csproj index f7d414f9..6b923cd2 100644 --- a/src/CSharpDB.Admin.Forms/CSharpDB.Admin.Forms.csproj +++ b/src/CSharpDB.Admin.Forms/CSharpDB.Admin.Forms.csproj @@ -10,6 +10,7 @@ + diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor new file mode 100644 index 00000000..e369d36e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor @@ -0,0 +1,581 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives +@inject DbCommandRegistry CommandRegistry + +
+ @if (ActionSequence is null) + { + + } + else + { +
+
+ + +
+ +
+ + @if (ActionSequence.Steps.Count == 0) + { +
No action steps
+ } + + @for (int i = 0; i < ActionSequence.Steps.Count; i++) + { + var idx = i; + var step = ActionSequence.Steps[idx]; +
+
+
+ + +
+ + + +
+ + @switch (step.Kind) + { + case DbActionKind.RunCommand: +
+ + @if (RegisteredCommands.Count > 0) + { + + } + else + { + + } +
+
+ + +
+ break; + case DbActionKind.RunActionSequence: +
+ + @if (AvailableActionSequences.Count > 0) + { + + } + else + { + + } +
+
+ + +
+ break; + case DbActionKind.SetFieldValue: +
+
+ + +
+
+ + +
+
+ break; + case DbActionKind.GoToRecord: +
+
+ + +
+
+ + +
+
+ break; + case DbActionKind.OpenForm: +
+ + +
+
+ + +
+ break; + case DbActionKind.ApplyFilter: +
+
+ + +
+
+ + +
+
+
+ + +
+ break; + case DbActionKind.ClearFilter: +
+ + +
+ break; + case DbActionKind.RunSql: +
+ + +
+
+ + +
+ break; + case DbActionKind.RunProcedure: +
+ + +
+
+ + +
+ break; + case DbActionKind.SetControlProperty: +
+
+ + +
+
+ + +
+
+ + +
+
+ break; + case DbActionKind.SetControlVisibility: + case DbActionKind.SetControlEnabled: + case DbActionKind.SetControlReadOnly: +
+
+ + +
+
+ + +
+
+ break; + case DbActionKind.ShowMessage: + case DbActionKind.Stop: +
+ + +
+ break; + } + +
+ + +
+ +
+ +
+
+ } + + @if (!string.IsNullOrWhiteSpace(_argumentError)) + { +
@_argumentError
+ } + +
+ + + + + + + + + + + + + + + + + + +
+ } +
+ +@code { + [Parameter] public DbActionSequence? ActionSequence { get; set; } + [Parameter] public EventCallback ActionSequenceChanged { get; set; } + [Parameter] public IReadOnlyList AvailableActionSequences { get; set; } = []; + + private readonly Dictionary _argumentText = []; + private string? _argumentError; + + private static readonly DbActionKind[] ActionKinds = Enum.GetValues(); + private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); + + protected override void OnParametersSet() + { + if (ActionSequence is null) + { + _argumentText.Clear(); + return; + } + + for (int i = 0; i < ActionSequence.Steps.Count; i++) + _argumentText.TryAdd(i, FormatArguments(ActionSequence.Steps[i].Arguments)); + + foreach (int staleIndex in _argumentText.Keys.Where(index => index >= ActionSequence.Steps.Count).ToList()) + _argumentText.Remove(staleIndex); + } + + private Task CreateSequence() + => ActionSequenceChanged.InvokeAsync(new DbActionSequence([])); + + private Task ClearSequence() + { + _argumentText.Clear(); + _argumentError = null; + return ActionSequenceChanged.InvokeAsync(null); + } + + private Task UpdateName(string? name) + => UpdateSequence(CurrentSequence() with { Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim() }); + + private Task AddStep(DbActionKind kind) + { + DbActionSequence sequence = CurrentSequence(); + List steps = sequence.Steps.ToList(); + steps.Add(CreateDefaultStep(kind)); + return UpdateSequence(sequence with { Steps = steps }); + } + + private Task RemoveStep(int index) + { + DbActionSequence sequence = CurrentSequence(); + List steps = sequence.Steps.ToList(); + if (index < 0 || index >= steps.Count) + return Task.CompletedTask; + + steps.RemoveAt(index); + RebuildArgumentText(steps); + return UpdateSequence(sequence with { Steps = steps }); + } + + private Task MoveStep(int index, int delta) + { + DbActionSequence sequence = CurrentSequence(); + List steps = sequence.Steps.ToList(); + int target = index + delta; + if (index < 0 || index >= steps.Count || target < 0 || target >= steps.Count) + return Task.CompletedTask; + + (steps[index], steps[target]) = (steps[target], steps[index]); + RebuildArgumentText(steps); + return UpdateSequence(sequence with { Steps = steps }); + } + + private Task UpdateKind(int index, DbActionStep step, string? value) + { + if (!Enum.TryParse(value, ignoreCase: true, out DbActionKind kind)) + return Task.CompletedTask; + + DbActionStep updated = CreateDefaultStep(kind) with + { + Arguments = kind is DbActionKind.RunCommand or DbActionKind.RunActionSequence ? step.Arguments : null, + SequenceName = kind == DbActionKind.RunActionSequence ? step.SequenceName : null, + StopOnFailure = step.StopOnFailure, + Condition = step.Condition, + }; + return ReplaceStep(index, updated); + } + + private Task UpdateCommandName(int index, DbActionStep step, string? commandName) + => ReplaceStep(index, step with { CommandName = string.IsNullOrWhiteSpace(commandName) ? null : commandName.Trim() }); + + private Task UpdateSequenceName(int index, DbActionStep step, string? sequenceName) + => ReplaceStep(index, step with { SequenceName = string.IsNullOrWhiteSpace(sequenceName) ? null : sequenceName.Trim() }); + + private Task UpdateTarget(int index, DbActionStep step, string? target) + => ReplaceStep(index, step with { Target = string.IsNullOrWhiteSpace(target) ? null : target.Trim() }); + + private Task UpdateValue(int index, DbActionStep step, string? value) + => ReplaceStep(index, step with { Value = string.IsNullOrEmpty(value) ? null : value }); + + private Task UpdateBooleanValue(int index, DbActionStep step, string? value) + => ReplaceStep(index, step with { Value = bool.TryParse(value, out bool parsed) ? parsed : null }); + + private Task UpdateMessage(int index, DbActionStep step, string? message) + => ReplaceStep(index, step with { Message = string.IsNullOrWhiteSpace(message) ? null : message }); + + private Task UpdateCondition(int index, DbActionStep step, string? condition) + => ReplaceStep(index, step with { Condition = string.IsNullOrWhiteSpace(condition) ? null : condition.Trim() }); + + private Task UpdateStepStopOnFailure(int index, DbActionStep step, bool stopOnFailure) + => ReplaceStep(index, step with { StopOnFailure = stopOnFailure }); + + private async Task UpdateArguments(int index, DbActionStep step, string text) + { + _argumentText[index] = text; + _argumentError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + await ReplaceStep(index, step with { Arguments = null }); + return; + } + + try + { + IReadOnlyDictionary? arguments = + JsonSerializer.Deserialize>(text, JsonDefaults.Options); + await ReplaceStep(index, step with { Arguments = arguments }); + } + catch (JsonException ex) + { + _argumentError = $"Invalid step arguments JSON: {ex.Message}"; + } + } + + private Task UpdateArgument(int index, DbActionStep step, string key, object? value) + { + var arguments = step.Arguments?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + if (value is string text && string.IsNullOrWhiteSpace(text)) + arguments.Remove(key); + else + arguments[key] = value; + + _argumentText[index] = FormatArguments(arguments); + return ReplaceStep(index, step with { Arguments = arguments.Count == 0 ? null : arguments }); + } + + private Task ReplaceStep(int index, DbActionStep step) + { + DbActionSequence sequence = CurrentSequence(); + List steps = sequence.Steps.ToList(); + if (index < 0 || index >= steps.Count) + return Task.CompletedTask; + + steps[index] = step; + return UpdateSequence(sequence with { Steps = steps }); + } + + private Task UpdateSequence(DbActionSequence sequence) + => ActionSequenceChanged.InvokeAsync(sequence); + + private DbActionSequence CurrentSequence() + => ActionSequence ?? new DbActionSequence([]); + + private DbActionStep CreateDefaultStep(DbActionKind kind) + => kind switch + { + DbActionKind.RunCommand => new DbActionStep(kind, CommandName: RegisteredCommands.FirstOrDefault()?.Name), + DbActionKind.RunActionSequence => new DbActionStep(kind, SequenceName: FirstAvailableActionSequenceName()), + DbActionKind.SetFieldValue => new DbActionStep(kind, Target: string.Empty, Value: string.Empty), + DbActionKind.GoToRecord => new DbActionStep(kind, Value: string.Empty), + DbActionKind.OpenForm => new DbActionStep(kind, Target: string.Empty), + DbActionKind.ApplyFilter => new DbActionStep(kind, Target: "form", Value: string.Empty), + DbActionKind.ClearFilter => new DbActionStep(kind, Target: "form"), + DbActionKind.RunSql => new DbActionStep(kind, Value: string.Empty), + DbActionKind.RunProcedure => new DbActionStep(kind, Target: string.Empty), + DbActionKind.SetControlProperty => new DbActionStep( + kind, + Target: string.Empty, + Value: string.Empty, + Arguments: new Dictionary { ["property"] = "visible" }), + DbActionKind.SetControlVisibility or + DbActionKind.SetControlEnabled or + DbActionKind.SetControlReadOnly => new DbActionStep(kind, Target: string.Empty, Value: true), + DbActionKind.ShowMessage => new DbActionStep(kind, Message: string.Empty), + DbActionKind.Stop => new DbActionStep(kind), + _ => new DbActionStep(kind), + }; + + private static string GetArgumentText(DbActionStep step, string key) + => step.Arguments is not null && step.Arguments.TryGetValue(key, out object? value) + ? value?.ToString() ?? string.Empty + : string.Empty; + + private string GetArgumentsText(int index, DbActionStep step) + { + if (_argumentText.TryGetValue(index, out string? text)) + return text; + + text = FormatArguments(step.Arguments); + _argumentText[index] = text; + return text; + } + + private void RebuildArgumentText(IReadOnlyList steps) + { + _argumentText.Clear(); + for (int i = 0; i < steps.Count; i++) + _argumentText[i] = FormatArguments(steps[i].Arguments); + } + + private bool ShouldRenderMissingCommand(string? commandName) + => !string.IsNullOrWhiteSpace(commandName) + && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + private bool ShouldRenderMissingActionSequence(string? sequenceName) + => !string.IsNullOrWhiteSpace(sequenceName) + && AvailableActionSequences.All(sequence => !string.Equals(sequence.Name, sequenceName, StringComparison.OrdinalIgnoreCase)); + + private string? FirstAvailableActionSequenceName() + => AvailableActionSequences.FirstOrDefault(sequence => !string.IsNullOrWhiteSpace(sequence.Name))?.Name; + + private static string FormatArguments(IReadOnlyDictionary? arguments) + => arguments is null || arguments.Count == 0 + ? string.Empty + : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); + + private static string FormatValue(object? value) + => value switch + { + null => string.Empty, + JsonElement { ValueKind: JsonValueKind.String } json => json.GetString() ?? string.Empty, + JsonElement json => json.ToString(), + _ => value.ToString() ?? string.Empty, + }; + + private static string GetActionLabel(DbActionKind kind) + => kind switch + { + DbActionKind.RunCommand => "Run Command", + DbActionKind.RunActionSequence => "Run Action Sequence", + DbActionKind.SetFieldValue => "Set Field Value", + DbActionKind.ShowMessage => "Show Message", + DbActionKind.Stop => "Stop", + DbActionKind.NewRecord => "New Record", + DbActionKind.SaveRecord => "Save Record", + DbActionKind.DeleteRecord => "Delete Record", + DbActionKind.RefreshRecords => "Refresh Records", + DbActionKind.PreviousRecord => "Previous Record", + DbActionKind.NextRecord => "Next Record", + DbActionKind.GoToRecord => "Go To Record", + _ => kind.ToString(), + }; +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor index cbc87210..ad3870cb 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor @@ -1,10 +1,11 @@ @using CSharpDB.Admin.Forms.Models @using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Services @inject IFormRecordService RecordService
- @ChildTableName (@_rows.Count) + @GetTitleText() @if (AllowAdd) { @@ -76,14 +77,35 @@ @if (_rows.Count == 0) { - - No child records. + + @(IsStandalone ? "No records." : "No child records.") }
+ @if (IsStandalone) + { +
+
+ +
+
+ + + Page @_pageNumber of @TotalPages + + +
+
@GetVisibleRangeText()
+
+ } }
@@ -91,6 +113,7 @@ [Parameter, EditorRequired] public string ChildTableName { get; set; } = ""; [Parameter, EditorRequired] public string ForeignKeyField { get; set; } = ""; [Parameter, EditorRequired] public object? ParentKeyValue { get; set; } + [Parameter] public bool IsStandalone { get; set; } [Parameter] public List VisibleColumns { get; set; } = []; [Parameter] public bool AllowAdd { get; set; } = true; [Parameter] public bool AllowEdit { get; set; } = true; @@ -99,6 +122,8 @@ [Parameter] public bool EnableRowSelection { get; set; } [Parameter] public EventCallback?> OnRowSelected { get; set; } [Parameter] public EventCallback OnRowsChanged { get; set; } + [Parameter] public string? FilterExpression { get; set; } + [Parameter] public IReadOnlyDictionary? FilterParameters { get; set; } private List> _rows = []; private bool _loading = true; @@ -111,6 +136,15 @@ private string? _lastChildTableName; private string? _lastForeignKeyField; private string? _lastChildTableDefinitionName; + private string? _lastFilterExpression; + private string _lastFilterParametersKey = string.Empty; + private bool _lastIsStandalone; + private int _pageNumber = 1; + private int _pageSize = 25; + private int _totalCount; + + private int TotalPages => Math.Max(1, (int)Math.Ceiling(_totalCount / (double)_pageSize)); + private bool HasActiveFilter => !string.IsNullOrWhiteSpace(FilterExpression); protected override async Task OnParametersSetAsync() { @@ -118,13 +152,23 @@ bool childTableChanged = !string.Equals(_lastChildTableName, ChildTableName, StringComparison.OrdinalIgnoreCase); bool foreignKeyChanged = !string.Equals(_lastForeignKeyField, ForeignKeyField, StringComparison.OrdinalIgnoreCase); bool definitionChanged = !string.Equals(_lastChildTableDefinitionName, ChildFormTableDefinition?.TableName, StringComparison.OrdinalIgnoreCase); + string filterParametersKey = FormatFilterParameterKey(FilterParameters); + bool filterChanged = !string.Equals(_lastFilterExpression, FilterExpression, StringComparison.Ordinal) || + !string.Equals(_lastFilterParametersKey, filterParametersKey, StringComparison.Ordinal); + bool modeChanged = _lastIsStandalone != IsStandalone; - if (parentChanged || childTableChanged || foreignKeyChanged || definitionChanged) + if (parentChanged || childTableChanged || foreignKeyChanged || definitionChanged || filterChanged || modeChanged) { + if (childTableChanged || definitionChanged || filterChanged || modeChanged) + _pageNumber = 1; + _lastParentKeyValue = ParentKeyValue; _lastChildTableName = ChildTableName; _lastForeignKeyField = ForeignKeyField; _lastChildTableDefinitionName = ChildFormTableDefinition?.TableName; + _lastFilterExpression = FilterExpression; + _lastFilterParametersKey = filterParametersKey; + _lastIsStandalone = IsStandalone; await LoadChildRecords(); } } @@ -143,15 +187,59 @@ if (ChildFormTableDefinition is null) { _rows = []; + _totalCount = 0; return; } + if (IsStandalone) + { + if (TryGetActiveFilter(out FormFilterExpression? standaloneFilter, out IReadOnlyDictionary standaloneParameters, out string? standaloneFilterError)) + { + if (standaloneFilterError is not null) + throw new InvalidOperationException(standaloneFilterError); + + List> filteredRows = (await RecordService.ListRecordsAsync(ChildFormTableDefinition)) + .Where(row => standaloneFilter!.Evaluate(row, standaloneParameters)) + .ToList(); + int totalPages = filteredRows.Count == 0 ? 1 : (int)Math.Ceiling(filteredRows.Count / (double)_pageSize); + _pageNumber = Math.Clamp(_pageNumber, 1, totalPages); + _totalCount = filteredRows.Count; + _rows = filteredRows + .Skip((_pageNumber - 1) * _pageSize) + .Take(_pageSize) + .ToList(); + return; + } + + FormRecordPage page = await RecordService.ListRecordPageAsync(ChildFormTableDefinition, _pageNumber, _pageSize); + _pageNumber = page.PageNumber; + _pageSize = page.PageSize; + _totalCount = page.TotalCount; + _rows = page.Records.ToList(); + return; + } + + if (string.IsNullOrWhiteSpace(ForeignKeyField)) + throw new InvalidOperationException("The related DataGrid foreign key field is not configured."); + _rows = await RecordService.ListFilteredRecordsAsync(ChildFormTableDefinition, ForeignKeyField, ParentKeyValue); + if (TryGetActiveFilter(out FormFilterExpression? relatedFilter, out IReadOnlyDictionary relatedParameters, out string? relatedFilterError)) + { + if (relatedFilterError is not null) + throw new InvalidOperationException(relatedFilterError); + + _rows = _rows + .Where(row => relatedFilter!.Evaluate(row, relatedParameters)) + .ToList(); + } + + _totalCount = _rows.Count; } catch (Exception ex) { _error = $"Failed to load: {ex.Message}"; _rows = []; + _totalCount = 0; } finally { @@ -168,12 +256,29 @@ if (ChildFormTableDefinition is null) throw new InvalidOperationException($"The child table '{ChildTableName}' is not available."); - var newRow = new Dictionary + var newRow = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!IsStandalone) { - [ForeignKeyField] = ParentKeyValue - }; + if (string.IsNullOrWhiteSpace(ForeignKeyField)) + throw new InvalidOperationException("The related DataGrid foreign key field is not configured."); + + newRow[ForeignKeyField] = ParentKeyValue; + } + var created = await RecordService.CreateRecordAsync(ChildFormTableDefinition, newRow); - _rows.Add(created); + if (IsStandalone || HasActiveFilter) + { + if (IsStandalone) + _pageNumber = GetTotalPages(_totalCount + 1, _pageSize); + + await LoadChildRecords(); + } + else + { + _rows.Add(created); + _totalCount = _rows.Count; + } + if (OnRowsChanged.HasDelegate) await OnRowsChanged.InvokeAsync(); } @@ -242,19 +347,31 @@ throw new InvalidOperationException($"The child table '{ChildTableName}' is not available."); await RecordService.DeleteRecordAsync(ChildFormTableDefinition, pk); - _rows.RemoveAt(rowIdx); - if (OnRowsChanged.HasDelegate) - await OnRowsChanged.InvokeAsync(); - if (_editingRow == rowIdx) StopEditing(); - if (_selectedRowIndex == rowIdx) + if (IsStandalone) { - _selectedRowIndex = -1; - await OnRowSelected.InvokeAsync(null); + if (_rows.Count == 1 && _pageNumber > 1) + _pageNumber--; + + await LoadChildRecords(); } - else if (_selectedRowIndex > rowIdx) + else { - _selectedRowIndex--; + _rows.RemoveAt(rowIdx); + _totalCount = _rows.Count; + if (_editingRow == rowIdx) StopEditing(); + if (_selectedRowIndex == rowIdx) + { + _selectedRowIndex = -1; + await OnRowSelected.InvokeAsync(null); + } + else if (_selectedRowIndex > rowIdx) + { + _selectedRowIndex--; + } } + + if (OnRowsChanged.HasDelegate) + await OnRowsChanged.InvokeAsync(); } catch (Exception ex) { @@ -318,4 +435,105 @@ } return fieldName; } + + private int GetEmptyColumnSpan() + => Math.Max(1, VisibleColumns.Count + (AllowDelete ? 1 : 0)); + + private async Task GoToPageAsync(int pageNumber) + { + if (!IsStandalone || _saving) + return; + + int targetPage = Math.Clamp(pageNumber, 1, TotalPages); + if (targetPage == _pageNumber && _rows.Count > 0) + return; + + _pageNumber = targetPage; + await LoadChildRecords(); + } + + private async Task OnPageSizeChanged(ChangeEventArgs e) + { + if (!IsStandalone || _saving) + return; + + if (!int.TryParse(e.Value?.ToString(), out int pageSize) || pageSize < 1) + return; + + pageSize = Math.Clamp(pageSize, 1, 500); + if (pageSize == _pageSize) + return; + + _pageSize = pageSize; + _pageNumber = 1; + await LoadChildRecords(); + } + + private string GetTitleText() + => IsStandalone + ? $"{ChildTableName} ({_totalCount} rows)" + : $"{ChildTableName} ({_rows.Count})"; + + private string GetVisibleRangeText() + { + if (_totalCount == 0 || _rows.Count == 0) + return "0 rows"; + + int first = ((_pageNumber - 1) * _pageSize) + 1; + int last = first + _rows.Count - 1; + return $"{first}-{last} of {_totalCount}"; + } + + private static int GetTotalPages(int totalCount, int pageSize) + => Math.Max(1, (int)Math.Ceiling(totalCount / (double)pageSize)); + + private bool TryGetActiveFilter( + out FormFilterExpression? filter, + out IReadOnlyDictionary parameters, + out string? error) + { + filter = null; + parameters = FilterParameters ?? EmptyObjectDictionary.Instance; + error = null; + + if (!HasActiveFilter) + return false; + + if (!FormFilterExpression.TryParse(FilterExpression!, ChildFormTableDefinition, out filter, out string? parseError)) + { + error = $"Invalid DataGrid filter: {parseError}"; + return true; + } + + IReadOnlyDictionary parameterSnapshot = parameters; + string? missingParameter = filter!.Parameters.FirstOrDefault(parameter => !parameterSnapshot.ContainsKey(parameter)); + if (missingParameter is not null) + { + error = $"DataGrid filter is missing parameter '@{missingParameter}'."; + return true; + } + + return true; + } + + private static string FormatFilterParameterKey(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + return string.Empty; + + return string.Join( + "\u001f", + parameters + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Select(pair => $"{pair.Key}={FormatFilterParameterValue(pair.Value)}")); + } + + private static string FormatFilterParameterValue(object? value) + => value is null ? "" : $"{value.GetType().FullName}:{value}"; + + private static class EmptyObjectDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor new file mode 100644 index 00000000..2f74b955 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor @@ -0,0 +1,206 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives +@inject DbCommandRegistry CommandRegistry + +
+ @if (EventBindings.Count == 0) + { +
No control events
+ } + + @for (int i = 0; i < EventBindings.Count; i++) + { + var idx = i; + var binding = EventBindings[idx]; +
+
+
+ + +
+ +
+ +
+ + @if (RegisteredCommands.Count > 0) + { + + } + else + { + + } +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+ } + + @if (!string.IsNullOrWhiteSpace(_argumentError)) + { +
@_argumentError
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList EventBindings { get; set; } = []; + [Parameter] public IReadOnlyList ActionSequences { get; set; } = []; + [Parameter] public EventCallback> EventBindingsChanged { get; set; } + + private readonly Dictionary _argumentText = []; + private string? _argumentError; + + private static readonly ControlEventKind[] EventKinds = Enum.GetValues(); + + private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); + + protected override void OnParametersSet() + { + for (int i = 0; i < EventBindings.Count; i++) + _argumentText.TryAdd(i, FormatArguments(EventBindings[i].Arguments)); + + foreach (int staleIndex in _argumentText.Keys.Where(index => index >= EventBindings.Count).ToList()) + _argumentText.Remove(staleIndex); + } + + private async Task AddBinding() + { + string commandName = RegisteredCommands.Count > 0 ? RegisteredCommands[0].Name : string.Empty; + var updated = EventBindings + .Append(new ControlEventBinding(ControlEventKind.OnClick, commandName)) + .ToList(); + _argumentText[updated.Count - 1] = string.Empty; + await EventBindingsChanged.InvokeAsync(updated); + } + + private async Task RemoveBinding(int index) + { + var updated = EventBindings.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated.RemoveAt(index); + RebuildArgumentText(updated); + await EventBindingsChanged.InvokeAsync(updated); + } + + private Task UpdateEvent(int index, ControlEventBinding binding, string? value) + { + if (!Enum.TryParse(value, ignoreCase: true, out ControlEventKind eventKind)) + return Task.CompletedTask; + + return ReplaceBinding(index, binding with { Event = eventKind }); + } + + private Task UpdateCommand(int index, ControlEventBinding binding, string commandName) + => ReplaceBinding(index, binding with { CommandName = commandName.Trim() }); + + private Task UpdateStopOnFailure(int index, ControlEventBinding binding, bool stopOnFailure) + => ReplaceBinding(index, binding with { StopOnFailure = stopOnFailure }); + + private async Task UpdateArguments(int index, ControlEventBinding binding, string text) + { + _argumentText[index] = text; + _argumentError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + await ReplaceBinding(index, binding with { Arguments = null }); + return; + } + + try + { + IReadOnlyDictionary? arguments = + JsonSerializer.Deserialize>(text, JsonDefaults.Options); + await ReplaceBinding(index, binding with { Arguments = arguments }); + } + catch (JsonException ex) + { + _argumentError = $"Invalid arguments JSON: {ex.Message}"; + } + } + + private Task UpdateActionSequence(int index, ControlEventBinding binding, DbActionSequence? actionSequence) + => ReplaceBinding(index, binding with { ActionSequence = actionSequence }); + + private async Task ReplaceBinding(int index, ControlEventBinding binding) + { + var updated = EventBindings.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated[index] = binding; + await EventBindingsChanged.InvokeAsync(updated); + } + + private string GetArgumentsText(int index, ControlEventBinding binding) + { + if (_argumentText.TryGetValue(index, out string? text)) + return text; + + text = FormatArguments(binding.Arguments); + _argumentText[index] = text; + return text; + } + + private void RebuildArgumentText(IReadOnlyList updated) + { + _argumentText.Clear(); + for (int i = 0; i < updated.Count; i++) + _argumentText[i] = FormatArguments(updated[i].Arguments); + } + + private bool ShouldRenderMissingCommand(string commandName) + => !string.IsNullOrWhiteSpace(commandName) + && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + private static string FormatArguments(IReadOnlyDictionary? arguments) + => arguments is null || arguments.Count == 0 + ? string.Empty + : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ControlRulesEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ControlRulesEditor.razor new file mode 100644 index 00000000..6997a43b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ControlRulesEditor.razor @@ -0,0 +1,246 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Serialization + +
+ @if (Rules.Count == 0) + { +
No control rules
+ } + + @for (int i = 0; i < Rules.Count; i++) + { + var ruleIndex = i; + var rule = Rules[ruleIndex]; +
+
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + @for (int effectIndex = 0; effectIndex < rule.Effects.Count; effectIndex++) + { + var idx = effectIndex; + var effect = rule.Effects[idx]; +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ } + + +
+
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList Rules { get; set; } = []; + [Parameter] public IReadOnlyList Controls { get; set; } = []; + [Parameter] public EventCallback> RulesChanged { get; set; } + + private static readonly string[] SupportedProperties = + [ + "visible", + "enabled", + "readOnly", + "required", + "styleVariant", + "validationMessage", + "text", + "value", + "placeholder", + ]; + + private async Task AddRule() + { + var updated = Rules + .Append(new ControlRuleDefinition(NextRuleId(), string.Empty, [])) + .ToList(); + await RulesChanged.InvokeAsync(updated); + } + + private async Task RemoveRule(int index) + { + var updated = Rules.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated.RemoveAt(index); + await RulesChanged.InvokeAsync(updated); + } + + private Task UpdateRuleId(int index, ControlRuleDefinition rule, string value) + => ReplaceRule(index, rule with { RuleId = value.Trim() }); + + private Task UpdateDescription(int index, ControlRuleDefinition rule, string? value) + => ReplaceRule(index, rule with { Description = string.IsNullOrWhiteSpace(value) ? null : value.Trim() }); + + private Task UpdateCondition(int index, ControlRuleDefinition rule, string value) + => ReplaceRule(index, rule with { Condition = value.Trim() }); + + private async Task AddEffect(int ruleIndex, ControlRuleDefinition rule) + { + string controlId = Controls.FirstOrDefault()?.ControlId ?? string.Empty; + var updatedEffects = rule.Effects + .Append(new ControlRuleEffect(controlId, "visible", true)) + .ToList(); + await ReplaceRule(ruleIndex, rule with { Effects = updatedEffects }); + } + + private async Task RemoveEffect(int ruleIndex, int effectIndex, ControlRuleDefinition rule) + { + var updatedEffects = rule.Effects.ToList(); + if (effectIndex < 0 || effectIndex >= updatedEffects.Count) + return; + + updatedEffects.RemoveAt(effectIndex); + await ReplaceRule(ruleIndex, rule with { Effects = updatedEffects }); + } + + private Task UpdateEffectControl(int ruleIndex, int effectIndex, ControlRuleDefinition rule, ControlRuleEffect effect, string value) + => ReplaceEffect(ruleIndex, effectIndex, rule, effect with { ControlId = value.Trim() }); + + private Task UpdateEffectProperty(int ruleIndex, int effectIndex, ControlRuleDefinition rule, ControlRuleEffect effect, string value) + => ReplaceEffect(ruleIndex, effectIndex, rule, effect with { Property = value.Trim() }); + + private Task UpdateEffectValue(int ruleIndex, int effectIndex, ControlRuleDefinition rule, ControlRuleEffect effect, string value) + => ReplaceEffect(ruleIndex, effectIndex, rule, effect with { Value = ParseValue(value) }); + + private async Task ReplaceEffect(int ruleIndex, int effectIndex, ControlRuleDefinition rule, ControlRuleEffect effect) + { + var updatedEffects = rule.Effects.ToList(); + if (effectIndex < 0 || effectIndex >= updatedEffects.Count) + return; + + updatedEffects[effectIndex] = effect; + await ReplaceRule(ruleIndex, rule with { Effects = updatedEffects }); + } + + private async Task ReplaceRule(int index, ControlRuleDefinition rule) + { + var updated = Rules.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated[index] = rule; + await RulesChanged.InvokeAsync(updated); + } + + private string NextRuleId() + { + const string prefix = "Rule"; + HashSet existing = Rules + .Select(rule => rule.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + for (int i = 1; ; i++) + { + string candidate = $"{prefix}{i}"; + if (!existing.Contains(candidate)) + return candidate; + } + } + + private bool ShouldRenderMissingControl(string controlId) + => !string.IsNullOrWhiteSpace(controlId) && + Controls.All(control => !string.Equals(control.ControlId, controlId, StringComparison.OrdinalIgnoreCase)); + + private static string GetControlLabel(ControlDefinition control) + => $"{control.ControlId} ({control.ControlType})"; + + private static string FormatValue(object? value) + => value switch + { + null => "null", + string text => text, + _ => JsonSerializer.Serialize(value, JsonDefaults.Options), + }; + + private static object? ParseValue(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + try + { + using JsonDocument doc = JsonDocument.Parse(text); + return ReadJsonValue(doc.RootElement); + } + catch (JsonException) + { + return text; + } + } + + private static object? ReadJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary( + static property => property.Name, + static property => ReadJsonValue(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => value.EnumerateArray().Select(ReadJsonValue).ToArray(), + _ => value.ToString(), + }; +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor b/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor index 0b1de80a..e07fa907 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor @@ -1,5 +1,6 @@ @using CSharpDB.Admin.Forms.Models @using CSharpDB.Admin.Forms.Components.Designer +@using CSharpDB.Admin.Forms.Contracts @implements IDisposable
@@ -23,6 +24,7 @@ @foreach (var control in State.Controls) { + if (IsNestedControl(control)) continue; if (!State.IsVisibleAtBreakpoint(control)) continue; var isSelected = State.SelectedIds.Contains(control.ControlId); var c = control; // capture for lambda @@ -32,7 +34,7 @@ @onpointerdown="e => OnItemPointerDown(e, c)" @onpointerdown:stopPropagation="true"> - @if (State.ShowTabOrder && c.Binding is not null) + @if (State.ShowTabOrder && IsTabOrderControl(c)) { var tabIdx = GetTabIndex(c); @if (tabIdx > 0) @@ -42,8 +44,15 @@ }
- @switch (c.ControlType) + @if (TryGetDesignerPreviewComponent(c, out Type? designerPreviewComponent)) { + + } + else + { + @switch (c.ControlType) + { case "label": @GetProp(c, "text", "Label") break; @@ -67,18 +76,40 @@ break; + case "comboBox": + + break; + case "listBox": + + break; case "lookup": var lkTable = GetProp(c, "lookupTable", ""); break; + case "optionGroup": +
+ ○ Option 1 + ○ Option 2 +
+ break; + case "toggleButton": + + break; case "computed": var formula = GetProp(c, "formula", ""); break; + case "commandButton": + + break; case "textarea": break; @@ -87,11 +118,17 @@ break; case "datagrid": var childTbl = GetProp(c, "childTable", ""); + var dataGridMode = GetProp(c, "dataGridMode", "related"); + var dataGridLabel = string.IsNullOrEmpty(childTbl) + ? "DataGrid (not configured)" + : string.Equals(dataGridMode, "standalone", StringComparison.OrdinalIgnoreCase) + ? $"Table Grid: {childTbl}" + : $"Related Grid: {childTbl}"; var dgCols = GetListProp(c, "visibleColumns");
- @(string.IsNullOrEmpty(childTbl) ? "DataGrid (not configured)" : $"DataGrid: {childTbl}") + @dataGridLabel
@@ -155,9 +192,111 @@ }
break; - default: - [@c.ControlType] + case "tabControl": + var tabPages = GetTabPages(c); + var visibleTabPages = tabPages.Count > 0 + ? tabPages + : new List<(string Id, string Label)> { ("tab1", "Page 1") }; + var activeTabPage = GetActiveDesignerTab(c, visibleTabPages); + var tabChildControls = GetTabChildControls(c.ControlId, activeTabPage.Id); +
+
+ + @(tabPages.Count == 0 ? "Tab Control (no pages)" : "Tab Control") +
+
+ @foreach (var tab in visibleTabPages) + { + var page = tab; + + } +
+
+ @if (tabChildControls.Count == 0) + { +
Assign child controls in the property inspector
+ } + else + { + @foreach (var child in tabChildControls) + { + var tabChild = child; + var childSelected = State.SelectedIds.Contains(tabChild.ControlId); +
+ @if (State.ShowTabOrder && IsTabOrderControl(tabChild)) + { + var tabIdx = GetTabIndex(tabChild); + @if (tabIdx > 0) + { +
@tabIdx
+ } + } + +
+ @if (TryGetDesignerPreviewComponent(tabChild, out Type? tabChildDesignerPreviewComponent)) + { + + } + else + { + @GetControlDisplayName(tabChild.ControlType) + @GetDesignControlTitle(tabChild) + } +
+ + @if (childSelected) + { + @foreach (var h in _handles) + { + var handle = h; +
+ } + } +
+ } + } +
+
+ break; + case "subform": + var formId = GetProp(c, "formId", ""); +
+
+ + @(string.IsNullOrWhiteSpace(formId) ? "Subform (not configured)" : $"Subform: {formId}") +
+
Embedded form
+
+ break; + case "attachment": +
+ + No file + +
break; + case "image": +
+ Image preview +
+ break; + default: + [@c.ControlType] + break; + } }
@@ -178,9 +317,11 @@ @code { [CascadingParameter] public DesignerState State { get; set; } = default!; + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; private ElementReference _canvasRef; private static readonly string[] _handles = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]; + private readonly Dictionary _activeDesignerTabs = new(StringComparer.Ordinal); // Marquee selection state private bool _isMarquee; @@ -216,6 +357,183 @@ return []; } + private static int GetIntProp(ControlDefinition c, string key, int fallback) + { + if (!c.Props.Values.TryGetValue(key, out var value) || value is null) + return fallback; + + if (value is int i) return i; + if (value is long l) return (int)l; + if (value is double d) return (int)d; + if (value is System.Text.Json.JsonElement je && je.TryGetInt32(out var ji)) return ji; + return int.TryParse(value.ToString(), out var parsed) ? parsed : fallback; + } + + private static bool IsNestedControl(ControlDefinition c) + => c.Props.Values.TryGetValue("parentControlId", out var value) + && !string.IsNullOrWhiteSpace(value?.ToString()); + + private static List<(string Id, string Label)> GetTabPages(ControlDefinition c) + { + if (!c.Props.Values.TryGetValue("tabs", out var value) || value is null) + return []; + + if (value is System.Text.Json.JsonElement json && json.ValueKind == System.Text.Json.JsonValueKind.Array) + { + return json.EnumerateArray() + .Select(element => ReadTabPage(element)) + .Where(tab => tab is not null) + .Select(tab => tab!.Value) + .ToList(); + } + + if (value is IEnumerable items) + { + return items + .Select(item => ReadTabPage(item)) + .Where(tab => tab is not null) + .Select(tab => tab!.Value) + .ToList(); + } + + return []; + } + + private static (string Id, string Label)? ReadTabPage(object? value) + { + if (value is System.Text.Json.JsonElement json) + { + if (json.ValueKind != System.Text.Json.JsonValueKind.Object) + return null; + + var id = json.TryGetProperty("id", out var idValue) ? idValue.ToString() : ""; + var label = json.TryGetProperty("label", out var labelValue) ? labelValue.ToString() : id; + return string.IsNullOrWhiteSpace(id) ? null : (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + if (value is IReadOnlyDictionary readOnly) + { + var id = readOnly.TryGetValue("id", out var idValue) ? idValue?.ToString() ?? "" : ""; + var label = readOnly.TryGetValue("label", out var labelValue) ? labelValue?.ToString() ?? id : id; + return string.IsNullOrWhiteSpace(id) ? null : (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + if (value is IDictionary dictionary) + { + var id = dictionary.TryGetValue("id", out var idValue) ? idValue?.ToString() ?? "" : ""; + var label = dictionary.TryGetValue("label", out var labelValue) ? labelValue?.ToString() ?? id : id; + return string.IsNullOrWhiteSpace(id) ? null : (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + return null; + } + + private (string Id, string Label) GetActiveDesignerTab(ControlDefinition control, IReadOnlyList<(string Id, string Label)> tabs) + { + if (tabs.Count == 0) + return ("tab1", "Page 1"); + + if (_activeDesignerTabs.TryGetValue(control.ControlId, out string? activeId)) + { + var active = tabs.FirstOrDefault(tab => string.Equals(tab.Id, activeId, StringComparison.Ordinal)); + if (!string.IsNullOrWhiteSpace(active.Id)) + return active; + } + + _activeDesignerTabs[control.ControlId] = tabs[0].Id; + return tabs[0]; + } + + private void SetActiveDesignerTab(string controlId, string tabId) + { + _activeDesignerTabs[controlId] = tabId; + StateHasChanged(); + } + + private static string GetPreviewTabClass(string activeTabId, string tabId) + => string.Equals(activeTabId, tabId, StringComparison.Ordinal) + ? "preview-childtabs-tab active" + : "preview-childtabs-tab"; + + private IReadOnlyList GetTabChildControls(string parentControlId, string tabId) + => State.Controls + .Where(control => State.IsVisibleAtBreakpoint(control) && IsTabChildControl(control, parentControlId, tabId)) + .ToList(); + + private static bool IsTabChildControl(ControlDefinition control, string parentControlId, string tabId) + => TryGetStringProp(control, "parentControlId", out string configuredParentId) + && string.Equals(configuredParentId, parentControlId, StringComparison.Ordinal) + && TryGetStringProp(control, "parentTabId", out string configuredTabId) + && string.Equals(configuredTabId, tabId, StringComparison.Ordinal); + + private static bool TryGetStringProp(ControlDefinition control, string key, out string value) + { + value = string.Empty; + if (!control.Props.Values.TryGetValue(key, out object? raw) || raw is null) + return false; + + if (raw is System.Text.Json.JsonElement json) + raw = json.ValueKind == System.Text.Json.JsonValueKind.String ? json.GetString() : json.ToString(); + + value = raw?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } + + private string GetTabChildStyle(ControlDefinition control) + { + var rect = State.GetEffectiveRect(control); + return FormattableString.Invariant($"left: {rect.X}px; top: {rect.Y}px; width: {rect.Width}px; height: {rect.Height}px;"); + } + + private string GetDesignControlTitle(ControlDefinition control) + { + var fieldName = control.Binding?.FieldName; + return control.ControlType switch + { + "label" => GetProp(control, "text", "Label"), + "checkbox" => GetProp(control, "text", fieldName ?? "Checkbox"), + "commandButton" => GetProp(control, "text", "Button"), + "toggleButton" => GetProp(control, "text", fieldName ?? "Toggle"), + "datagrid" => GetProp(control, "childTable", "DataGrid"), + "subform" => GetProp(control, "formId", "Subform"), + _ when !string.IsNullOrWhiteSpace(fieldName) => $"{GetControlDisplayName(control.ControlType)}: {fieldName}", + _ => GetControlDisplayName(control.ControlType) + }; + } + + private FormControlDescriptor? GetControlDescriptor(string controlType) + => ControlRegistry.TryGetControl(controlType, out FormControlDescriptor descriptor) ? descriptor : null; + + private string GetControlDisplayName(string controlType) + => GetControlDescriptor(controlType)?.DisplayName ?? controlType; + + private bool TryGetDesignerPreviewComponent(ControlDefinition control, out Type componentType) + { + componentType = default!; + FormControlDescriptor? descriptor = GetControlDescriptor(control.ControlType); + if (descriptor?.DesignerPreviewComponentType is null) + return false; + + componentType = descriptor.DesignerPreviewComponentType; + return true; + } + + private Dictionary GetDesignerPreviewParameters(ControlDefinition control, bool isSelected, Rect effectiveRect) + { + FormControlDescriptor descriptor = GetControlDescriptor(control.ControlType) + ?? new FormControlDescriptor + { + ControlType = control.ControlType, + DisplayName = control.ControlType, + ShowInToolbox = false, + }; + + return new Dictionary + { + ["Context"] = new FormControlDesignContext(control, State, isSelected, effectiveRect, descriptor), + }; + } + private void OnCanvasPointerDown(PointerEventArgs e) { if (e.Button != 0) return; @@ -244,55 +562,17 @@ var y = State.Snap(e.OffsetY); var controlType = State.ActiveTool!; - var (width, height) = controlType switch - { - "label" => (180.0, 34.0), - "checkbox" => (200.0, 34.0), - "textarea" => (320.0, 80.0), - "radio" => (200.0, 80.0), - "datagrid" => (560.0, 200.0), - "childtabs" => (600.0, 280.0), - _ => (320.0, 34.0) - }; - - var props = new Dictionary(); - if (controlType == "label") - props["text"] = "Label"; - if (controlType == "checkbox") - props["text"] = "Checkbox"; - if (controlType == "datagrid") - { - props["childTable"] = ""; - props["foreignKeyField"] = ""; - props["parentKeyField"] = ""; - props["foreignKeyName"] = ""; - props["visibleColumns"] = Array.Empty(); - props["allowAdd"] = true; - props["allowDelete"] = true; - props["allowEdit"] = true; - } - if (controlType == "childtabs") - { - props["tabs"] = Array.Empty(); - } - if (controlType == "lookup") - { - props["lookupTable"] = ""; - props["displayField"] = ""; - props["valueField"] = ""; - props["placeholder"] = "-- Select --"; - } - if (controlType == "computed") - { - props["formula"] = ""; - props["format"] = ""; - } + FormControlDescriptor? descriptor = GetControlDescriptor(controlType); + var (width, height) = descriptor is null + ? (320.0, 34.0) + : (descriptor.DefaultWidth, descriptor.DefaultHeight); + Dictionary props = descriptor?.CreateDefaultProps() ?? []; var control = new ControlDefinition( ControlId: Guid.NewGuid().ToString("N"), ControlType: controlType, Rect: new Rect(x, y, width, height), - Binding: (controlType is "label" or "datagrid" or "childtabs") ? null : new BindingDefinition("", "TwoWay"), + Binding: (descriptor?.SupportsBinding ?? true) ? new BindingDefinition("", "TwoWay") : null, Props: new PropertyBag(props), ValidationOverride: null); @@ -412,6 +692,10 @@ return 0; } + private bool IsTabOrderControl(ControlDefinition c) + => GetControlDescriptor(c.ControlType)?.ParticipatesInTabOrder + ?? (c.ControlType is not ("label" or "datagrid" or "childtabs" or "tabControl" or "subform")); + private void ApplyResize(PointerEventArgs e) { var o = State.ResizeOriginRect!; diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs index 77eef41a..8f432972 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs @@ -1,10 +1,15 @@ using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Components.Designer; public class DesignerState { private readonly List _controls = []; + private readonly List _eventBindings = []; + private readonly List _actionSequences = []; + private readonly List _rules = []; + private readonly List _validationRules = []; private readonly Stack> _undoStack = new(); private readonly Stack> _redoStack = new(); @@ -16,6 +21,10 @@ public class DesignerState public LayoutDefinition Layout { get; private set; } = new("absolute", 8, true, [new Breakpoint("md", 0, null)]); public IReadOnlyList Controls => _controls; + public IReadOnlyList EventBindings => _eventBindings; + public IReadOnlyList ActionSequences => _actionSequences; + public IReadOnlyList Rules => _rules; + public IReadOnlyList ValidationRules => _validationRules; public HashSet SelectedIds { get; } = []; // Active tool from toolbox (null = select mode) @@ -48,6 +57,28 @@ public class DesignerState public double GridSize => Layout.GridSize; public bool SnapToGrid => Layout.SnapToGrid; + public void SetLayoutMode(string layoutMode) + { + if (string.IsNullOrWhiteSpace(layoutMode) || string.Equals(Layout.LayoutMode, layoutMode, StringComparison.OrdinalIgnoreCase)) + return; + + Layout = Layout with { LayoutMode = layoutMode }; + NotifyChanged(); + } + + public void SetFormName(string? formName) + { + string normalized = string.IsNullOrWhiteSpace(formName) + ? "Untitled Form" + : formName.Trim(); + + if (string.Equals(FormName, normalized, StringComparison.Ordinal)) + return; + + FormName = normalized; + NotifyChanged(); + } + public double Snap(double v) { if (!SnapToGrid) return v; @@ -58,6 +89,14 @@ public void LoadForm(FormDefinition form) { _controls.Clear(); _controls.AddRange(form.Controls); + _eventBindings.Clear(); + _eventBindings.AddRange(form.EventBindings ?? []); + _actionSequences.Clear(); + _actionSequences.AddRange(form.ActionSequences ?? []); + _rules.Clear(); + _rules.AddRange(form.Rules ?? []); + _validationRules.Clear(); + _validationRules.AddRange(form.ValidationRules ?? []); _undoStack.Clear(); _redoStack.Clear(); SelectedIds.Clear(); @@ -82,7 +121,53 @@ public FormDefinition ToFormDefinition() { return new FormDefinition( FormId, FormName, TableName, DefinitionVersion, SourceSchemaSignature, - Layout, _controls.ToList()); + Layout, _controls.ToList(), EventBindings: _eventBindings.ToList(), ActionSequences: _actionSequences.ToList(), Rules: _rules.ToList(), ValidationRules: _validationRules.ToList()); + } + + public void UpdateEventBindings(IReadOnlyList bindings) + { + _eventBindings.Clear(); + _eventBindings.AddRange(bindings); + NotifyChanged(); + } + + public void UpdateActionSequences(IReadOnlyList sequences) + { + _actionSequences.Clear(); + _actionSequences.AddRange(sequences); + NotifyChanged(); + } + + public void UpdateRules(IReadOnlyList rules) + { + _rules.Clear(); + _rules.AddRange(rules); + NotifyChanged(); + } + + public void UpdateValidationRules(IReadOnlyList rules) + { + _validationRules.Clear(); + _validationRules.AddRange(rules); + NotifyChanged(); + } + + public void UpdateControlValidationOverride(string controlId, ValidationOverride? validationOverride) + { + var idx = _controls.FindIndex(c => c.ControlId == controlId); + if (idx < 0) return; + PushUndo(); + _controls[idx] = _controls[idx] with { ValidationOverride = validationOverride }; + NotifyChanged(); + } + + public void UpdateControlEventBindings(string controlId, IReadOnlyList bindings) + { + var idx = _controls.FindIndex(c => c.ControlId == controlId); + if (idx < 0) return; + PushUndo(); + _controls[idx] = _controls[idx] with { EventBindings = bindings.ToList() }; + NotifyChanged(); } public void PushUndo() @@ -146,7 +231,23 @@ public void DeleteSelected() { if (SelectedIds.Count == 0) return; PushUndo(); - _controls.RemoveAll(c => SelectedIds.Contains(c.ControlId)); + var idsToDelete = new HashSet(SelectedIds, StringComparer.Ordinal); + bool added; + do + { + added = false; + foreach (var child in _controls) + { + if (TryGetParentControlId(child, out string parentControlId) && + idsToDelete.Contains(parentControlId) && + idsToDelete.Add(child.ControlId)) + { + added = true; + } + } + } while (added); + + _controls.RemoveAll(c => idsToDelete.Contains(c.ControlId)); SelectedIds.Clear(); NotifyChanged(); } @@ -177,6 +278,22 @@ public void UpdateControlProp(string controlId, string key, object? value) NotifyChanged(); } + public void UpdateControlProps(string controlId, IReadOnlyDictionary values) + { + if (values.Count == 0) return; + var idx = _controls.FindIndex(c => c.ControlId == controlId); + if (idx < 0) return; + PushUndo(); + var c = _controls[idx]; + var newValues = new Dictionary(c.Props.Values); + + foreach (var (key, value) in values) + newValues[key] = value; + + _controls[idx] = c with { Props = new PropertyBag(newValues) }; + NotifyChanged(); + } + public void UpdateControlType(string controlId, string newType) { var idx = _controls.FindIndex(c => c.ControlId == controlId); @@ -261,8 +378,24 @@ public void MoveToIndex(string controlId, int newIndex) public void CopySelected() { + var idsToCopy = new HashSet(SelectedIds, StringComparer.Ordinal); + bool added; + do + { + added = false; + foreach (var child in _controls) + { + if (TryGetParentControlId(child, out string parentControlId) && + idsToCopy.Contains(parentControlId) && + idsToCopy.Add(child.ControlId)) + { + added = true; + } + } + } while (added); + _clipboard = _controls - .Where(c => SelectedIds.Contains(c.ControlId)) + .Where(c => idsToCopy.Contains(c.ControlId)) .ToList(); } @@ -272,12 +405,25 @@ public void PasteClipboard() PushUndo(); SelectedIds.Clear(); + var idMap = _clipboard.ToDictionary( + control => control.ControlId, + _ => Guid.NewGuid().ToString("N"), + StringComparer.Ordinal); + foreach (var original in _clipboard) { + var props = new Dictionary(original.Props.Values); + if (TryGetParentControlId(original, out string parentControlId) && + idMap.TryGetValue(parentControlId, out string? pastedParentId)) + { + props["parentControlId"] = pastedParentId; + } + var pasted = original with { - ControlId = Guid.NewGuid().ToString("N"), - Rect = original.Rect with { X = original.Rect.X + 16, Y = original.Rect.Y + 16 } + ControlId = idMap[original.ControlId], + Rect = original.Rect with { X = original.Rect.X + 16, Y = original.Rect.Y + 16 }, + Props = new PropertyBag(props) }; _controls.Add(pasted); SelectedIds.Add(pasted.ControlId); @@ -371,6 +517,19 @@ private void MoveControlInternal(string controlId, double x, double y) _controls[idx] = c with { Rect = c.Rect with { X = x, Y = y } }; } + private static bool TryGetParentControlId(ControlDefinition control, out string parentControlId) + { + parentControlId = string.Empty; + if (!control.Props.Values.TryGetValue("parentControlId", out object? value) || value is null) + return false; + + if (value is System.Text.Json.JsonElement json) + value = json.ValueKind == System.Text.Json.JsonValueKind.String ? json.GetString() : json.ToString(); + + parentControlId = value?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(parentControlId); + } + // ===== Responsive Breakpoints ===== public double GetCanvasWidth() diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormActionSequencesEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormActionSequencesEditor.razor new file mode 100644 index 00000000..8b87ace5 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormActionSequencesEditor.razor @@ -0,0 +1,65 @@ +@using CSharpDB.Primitives + +
+ @if (ActionSequences.Count == 0) + { +
No reusable action sequences
+ } + + @for (int i = 0; i < ActionSequences.Count; i++) + { + var idx = i; + var sequence = ActionSequences[idx]; +
+ +
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList ActionSequences { get; set; } = []; + [Parameter] public EventCallback> ActionSequencesChanged { get; set; } + + private async Task AddSequence() + { + var updated = ActionSequences + .Append(new DbActionSequence([], Name: NextSequenceName())) + .ToList(); + await ActionSequencesChanged.InvokeAsync(updated); + } + + private async Task UpdateSequence(int index, DbActionSequence? sequence) + { + var updated = ActionSequences.ToList(); + if (index < 0 || index >= updated.Count) + return; + + if (sequence is null) + updated.RemoveAt(index); + else + updated[index] = sequence; + + await ActionSequencesChanged.InvokeAsync(updated); + } + + private string NextSequenceName() + { + const string prefix = "Sequence"; + HashSet existing = ActionSequences + .Select(sequence => sequence.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Select(name => name!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + for (int i = 1; ; i++) + { + string candidate = $"{prefix}{i}"; + if (!existing.Contains(candidate)) + return candidate; + } + } +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor new file mode 100644 index 00000000..c1f14137 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor @@ -0,0 +1,205 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives +@inject DbCommandRegistry CommandRegistry + +
+ @if (EventBindings.Count == 0) + { +
No form events
+ } + + @for (int i = 0; i < EventBindings.Count; i++) + { + var idx = i; + var binding = EventBindings[idx]; +
+
+
+ + +
+ +
+ +
+ + @if (RegisteredCommands.Count > 0) + { + + } + else + { + + } +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+ } + + @if (!string.IsNullOrWhiteSpace(_argumentError)) + { +
@_argumentError
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList EventBindings { get; set; } = []; + [Parameter] public IReadOnlyList ActionSequences { get; set; } = []; + [Parameter] public EventCallback> EventBindingsChanged { get; set; } + + private readonly Dictionary _argumentText = []; + private string? _argumentError; + + private static readonly FormEventKind[] EventKinds = Enum.GetValues(); + + private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); + + protected override void OnParametersSet() + { + for (int i = 0; i < EventBindings.Count; i++) + _argumentText.TryAdd(i, FormatArguments(EventBindings[i].Arguments)); + + foreach (int staleIndex in _argumentText.Keys.Where(index => index >= EventBindings.Count).ToList()) + _argumentText.Remove(staleIndex); + } + + private async Task AddBinding() + { + string commandName = RegisteredCommands.Count > 0 ? RegisteredCommands[0].Name : string.Empty; + var updated = EventBindings + .Append(new FormEventBinding(FormEventKind.OnLoad, commandName)) + .ToList(); + _argumentText[updated.Count - 1] = string.Empty; + await EventBindingsChanged.InvokeAsync(updated); + } + + private async Task RemoveBinding(int index) + { + var updated = EventBindings.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated.RemoveAt(index); + RebuildArgumentText(updated); + await EventBindingsChanged.InvokeAsync(updated); + } + + private Task UpdateEvent(int index, FormEventBinding binding, string? value) + { + if (!Enum.TryParse(value, ignoreCase: true, out FormEventKind eventKind)) + return Task.CompletedTask; + + return ReplaceBinding(index, binding with { Event = eventKind }); + } + + private Task UpdateCommand(int index, FormEventBinding binding, string commandName) + => ReplaceBinding(index, binding with { CommandName = commandName.Trim() }); + + private Task UpdateStopOnFailure(int index, FormEventBinding binding, bool stopOnFailure) + => ReplaceBinding(index, binding with { StopOnFailure = stopOnFailure }); + + private async Task UpdateArguments(int index, FormEventBinding binding, string text) + { + _argumentText[index] = text; + _argumentError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + await ReplaceBinding(index, binding with { Arguments = null }); + return; + } + + try + { + IReadOnlyDictionary? arguments = + JsonSerializer.Deserialize>(text, JsonDefaults.Options); + await ReplaceBinding(index, binding with { Arguments = arguments }); + } + catch (JsonException ex) + { + _argumentError = $"Invalid arguments JSON: {ex.Message}"; + } + } + + private Task UpdateActionSequence(int index, FormEventBinding binding, DbActionSequence? actionSequence) + => ReplaceBinding(index, binding with { ActionSequence = actionSequence }); + + private async Task ReplaceBinding(int index, FormEventBinding binding) + { + var updated = EventBindings.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated[index] = binding; + await EventBindingsChanged.InvokeAsync(updated); + } + + private string GetArgumentsText(int index, FormEventBinding binding) + { + if (_argumentText.TryGetValue(index, out string? text)) + return text; + + text = FormatArguments(binding.Arguments); + _argumentText[index] = text; + return text; + } + + private void RebuildArgumentText(IReadOnlyList updated) + { + _argumentText.Clear(); + for (int i = 0; i < updated.Count; i++) + _argumentText[i] = FormatArguments(updated[i].Arguments); + } + + private bool ShouldRenderMissingCommand(string commandName) + => !string.IsNullOrWhiteSpace(commandName) + && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + private static string FormatArguments(IReadOnlyDictionary? arguments) + => arguments is null || arguments.Count == 0 + ? string.Empty + : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 1d75773b..64c761da 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -1,7 +1,15 @@ +@using System.Globalization +@using System.Text.Json +@using CSharpDB.Admin.Forms.Contracts @using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Pages +@using CSharpDB.Admin.Forms.Services +@using CSharpDB.Primitives +@inject DbCommandRegistry Commands +@inject DbExtensionPolicy? CallbackPolicy -
- @foreach (var control in Form.Controls) +
+ @foreach (var control in GetControlsToRender()) { var c = control; var fieldName = c.Binding?.FieldName; @@ -9,74 +17,135 @@ var hasError = fieldName is not null && ValidationErrors?.ContainsKey(fieldName) == true; var errorMsg = hasError ? ValidationErrors![fieldName!] : null; var inputClass = hasError ? "fr-input fr-field-error" : "fr-input"; -
- @switch (c.ControlType) +
+ @if (TryGetRuntimeComponent(c, out Type? runtimeComponent)) { + + } + else + { + @switch (c.ControlType) + { case "label": - @GetProp(c, "text", "Label") + @GetProp(c, "text", "Label") break; case "text": + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, e.Value))" /> break; case "number": + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, ParseNumber(e.Value)))" /> break; case "date": + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, e.Value))" /> break; case "checkbox": break; case "select": + var selectChoices = GetControlChoices(c, fieldName); break; case "lookup": + var lookupChoices = GetControlChoices(c, fieldName); + break; + case "comboBox": + var comboChoices = GetControlChoices(c, fieldName); + var comboListId = $"combo_{c.ControlId}"; + + + @foreach (var ch in comboChoices) + { + + } + + break; + case "listBox": + var listChoices = GetControlChoices(c, fieldName); + var listMultiSelect = IsListBoxMultiSelect(c); + break; @@ -84,12 +153,16 @@ + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, e.Value))"> break; case "radio": - @if (Choices?.TryGetValue(fieldName ?? "", out var radioChoices) == true && radioChoices is not null) + var radioChoices = GetControlChoices(c, fieldName); + @if (radioChoices.Count > 0) {
@foreach (var ch in radioChoices) @@ -100,14 +173,53 @@ name="@($"radio_{c.ControlId}")" value="@val" checked="@IsRadioChoiceSelected(fieldName, val)" + disabled="@(!IsControlEnabled(c) || IsControlReadOnly(c))" tabindex="@tabIdx" - @onchange="@(e => SetRadioFieldValue(fieldName, val))" /> + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetRadioFieldValueAsync(c, fieldName, val))" /> @ch.Label }
} break; + case "optionGroup": + var optionChoices = GetControlChoices(c, fieldName); + var orientation = GetProp(c, "orientation", "vertical"); + var buttonStyle = GetBoolProp(c, "buttonStyle") || string.Equals(GetProp(c, "buttonStyle", ""), "buttons", StringComparison.OrdinalIgnoreCase); +
+ @foreach (var ch in optionChoices) + { + var val = ch.Value; + + } +
+ break; + case "toggleButton": + var toggleOn = IsToggleButtonOn(c, fieldName); + + break; case "computed": var computedValue = GetFieldValue(fieldName); var computedFormat = GetProp(c, "format", ""); @@ -120,31 +232,189 @@ class="fr-input fr-computed" value="@displayValue" readonly - tabindex="@tabIdx" /> + tabindex="@tabIdx" + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" /> + break; + case "commandButton": + var isExecuting = _executingCommandButtons.Contains(c.ControlId); + + break; + case "tabControl": + var tabPages = GetTabPages(c); + @if (tabPages.Count == 0) + { +
Tab control has no pages.
+ } + else + { + var activeTab = GetActiveTabPage(c, tabPages); + var childForm = BuildTabChildForm(c, activeTab.Id); +
+
+ @foreach (var tab in tabPages) + { + var page = tab; + + } +
+
+ @if (childForm.Controls.Count == 0) + { +
No controls on this tab.
+ } + else + { + + } +
+
+ } + break; + case "subform": + var subformId = GetProp(c, "formId", ""); + var subformParentField = GetProp(c, "parentKeyField", ""); + var subformForeignField = GetProp(c, "foreignKeyField", ""); + var subformParentValue = GetFieldObjectValue(subformParentField); + @if (string.IsNullOrWhiteSpace(subformId) || + string.IsNullOrWhiteSpace(subformParentField) || + string.IsNullOrWhiteSpace(subformForeignField)) + { +
Subform not configured.
+ } + else if (subformParentValue is null || string.IsNullOrWhiteSpace(subformParentValue.ToString())) + { +
Save the parent record first to see child records.
+ } + else + { +
+ +
+ } + break; + case "attachment": +
+
@GetBlobSummary(c, fieldName)
+
+ + +
+
+ break; + case "image": + var imageSrc = GetImageDataUrl(c, fieldName); +
+ @if (!string.IsNullOrWhiteSpace(imageSrc)) + { + @GetProp(c, + } + else + { +
No image
+ } +
+ + +
+
break; case "datagrid": + var dgMode = GetProp(c, "dataGridMode", "related"); + var dgIsStandalone = string.Equals(dgMode, "standalone", StringComparison.OrdinalIgnoreCase); var dgChildTable = GetProp(c, "childTable", ""); var dgFkField = GetProp(c, "foreignKeyField", ""); var dgPkField = GetProp(c, "parentKeyField", ""); - var dgColumns = GetListProp(c, "visibleColumns"); + var dgTableDefinition = ChildFormTableDefinitions?.GetValueOrDefault(dgChildTable); + var dgColumns = GetDataGridVisibleColumns(c, dgTableDefinition); var dgAllowAdd = GetBoolProp(c, "allowAdd"); var dgAllowEdit = GetBoolProp(c, "allowEdit"); var dgAllowDelete = GetBoolProp(c, "allowDelete"); var parentPkValue = GetFieldObjectValue(dgPkField); + var dgFilter = GetControlFilter(c.ControlId); - @if (!string.IsNullOrEmpty(dgChildTable) && !string.IsNullOrEmpty(dgFkField) && parentPkValue is not null) + @if (!string.IsNullOrEmpty(dgChildTable) && dgIsStandalone) + { + + } + else if (!string.IsNullOrEmpty(dgChildTable) && !string.IsNullOrEmpty(dgFkField) && parentPkValue is not null) { } - else if (parentPkValue is null && !string.IsNullOrEmpty(dgChildTable)) + else if (!dgIsStandalone && parentPkValue is null && !string.IsNullOrEmpty(dgChildTable)) {
Save the parent record first to see child records.
} @@ -169,9 +439,10 @@
Child Tabs not configured.
} break; - default: - [@c.ControlType] - break; + default: + [@c.ControlType] + break; + } } @if (errorMsg is not null) { @@ -191,6 +462,304 @@ [Parameter] public Dictionary? ValidationErrors { get; set; } [Parameter] public IReadOnlyDictionary? ChildFormTableDefinitions { get; set; } [Parameter] public EventCallback OnChildRowsChanged { get; set; } + [Parameter] public EventCallback OnCommandError { get; set; } + [Parameter] public Func>? OnBuiltInAction { get; set; } + [Parameter] public IFormActionRuntime? ActionRuntime { get; set; } + [Parameter] public IReadOnlyDictionary>? ControlPropertyOverrides { get; set; } + [Parameter] public IReadOnlyDictionary? ControlFilters { get; set; } + [Parameter] public bool RenderAllControls { get; set; } + [Parameter] public double AnchorCanvasWidth { get; set; } = DesktopCanvasWidth; + [Parameter] public double? AnchorCanvasHeight { get; set; } + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; + + private readonly HashSet _executingCommandButtons = []; + private readonly Dictionary _activeTabs = new(StringComparer.Ordinal); + private const string LayoutModeElastic = "elastic"; + private const double DesktopCanvasWidth = 1200; + private const double TabletCanvasWidth = 768; + private const long DefaultMaxUploadBytes = 10 * 1024 * 1024; + private DbExtensionPolicy EffectiveCallbackPolicy => CallbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; + + private bool TryGetRuntimeComponent(ControlDefinition control, out Type componentType) + { + componentType = default!; + if (!ControlRegistry.TryGetControl(control.ControlType, out FormControlDescriptor descriptor) || + descriptor.RuntimeComponentType is null) + { + return false; + } + + if (descriptor.IsBuiltIn && !descriptor.ReplaceBuiltInRuntime) + return false; + + componentType = descriptor.RuntimeComponentType; + return true; + } + + private Dictionary GetRuntimeComponentParameters( + ControlDefinition control, + string? fieldName, + string? validationError, + int tabIndex) + { + FormControlDescriptor descriptor = ControlRegistry.TryGetControl(control.ControlType, out FormControlDescriptor registered) + ? registered + : new FormControlDescriptor + { + ControlType = control.ControlType, + DisplayName = control.ControlType, + ShowInToolbox = false, + }; + + return new Dictionary + { + ["Context"] = new FormControlRuntimeContext( + Form, + control, + descriptor, + TableDefinition, + Record, + fieldName, + GetFieldObjectValue(fieldName), + GetControlChoices(control, fieldName), + IsControlEnabled(control), + IsControlReadOnly(control), + validationError, + tabIndex, + value => SetFieldValueAsync(control, fieldName, value), + (eventKind, arguments) => InvokeControlEventsAsync(control, eventKind, arguments)), + }; + } + + private bool IsElasticLayout + => string.Equals(Form.Layout.LayoutMode, LayoutModeElastic, StringComparison.OrdinalIgnoreCase); + + private IEnumerable GetControlsToRender() + => RenderAllControls + ? Form.Controls + : Form.Controls.Where(IsTopLevelControl); + + private static bool IsTopLevelControl(ControlDefinition control) + => !FormChoiceResolver.TryGetString(control.Props.Values, "parentControlId", out _); + + private string GetRendererClass() + => IsElasticLayout + ? "form-renderer form-renderer-elastic" + : "form-renderer form-renderer-fixed"; + + private string GetRendererStyle() + => FormattableString.Invariant($"--fr-canvas-height: {GetCanvasHeight()}px;"); + + private string GetControlClass(ControlDefinition c) + { + List classes = ["fr-control", $"fr-control-{c.ControlType}"]; + + if (!IsVisibleAtBreakpoint(c, "mobile")) + classes.Add("fr-hidden-mobile"); + if (!IsVisibleAtBreakpoint(c, "tablet")) + classes.Add("fr-hidden-tablet"); + + return string.Join(" ", classes); + } + + private string GetControlStyle(ControlDefinition c) + { + Rect desktop = c.Rect; + Rect tablet = GetEffectiveRect(c, "tablet"); + int desktopSpan = GetGridSpan(desktop, 12, DesktopCanvasWidth); + int tabletSpan = GetGridSpan(tablet, 8, TabletCanvasWidth); + + ControlAnchors anchors = GetControlAnchors(c); + double canvasWidth = GetAnchorCanvasWidth(); + double canvasHeight = GetAnchorCanvasHeight(); + + string resizeMode = GetProp(c, "resizeMode", "anchor"); + string left; + string right; + string width; + string top; + string bottom; + string height; + + if (string.Equals(resizeMode, "scale", StringComparison.OrdinalIgnoreCase)) + { + left = ToCssPercent(desktop.X, canvasWidth); + right = "auto"; + width = ToCssPercent(desktop.Width, canvasWidth); + top = ToCssPercent(desktop.Y, canvasHeight); + bottom = "auto"; + height = ToCssPercent(desktop.Height, canvasHeight); + } + else + { + left = anchors.Left || !anchors.Right ? ToCssPx(desktop.X) : "auto"; + right = anchors.Right ? ToCssPx(Math.Max(0, canvasWidth - desktop.X - desktop.Width)) : "auto"; + width = anchors.Left && anchors.Right ? "auto" : ToCssPx(desktop.Width); + + top = anchors.Top || !anchors.Bottom ? ToCssPx(desktop.Y) : "auto"; + bottom = anchors.Bottom ? ToCssPx(Math.Max(0, canvasHeight - desktop.Y - desktop.Height)) : "auto"; + height = anchors.Top && anchors.Bottom ? "auto" : ToCssPx(desktop.Height); + } + + double minWidth = Math.Max(0, GetDoubleProp(c, "minWidth", 24)); + double minHeight = Math.Max(0, GetDoubleProp(c, "minHeight", 16)); + double elasticMinHeight = anchors.Top && anchors.Bottom ? minHeight : Math.Max(minHeight, desktop.Height); + string visibilityStyle = IsControlVisible(c) ? string.Empty : " display: none;"; + return FormattableString.Invariant( + $"--fr-left: {left}; --fr-top: {top}; --fr-right: {right}; --fr-bottom: {bottom}; --fr-width: {width}; --fr-height: {height}; --fr-min-width: {minWidth}px; --fr-min-height: {minHeight}px; --fr-elastic-min-height: {elasticMinHeight}px; --fr-stack-order: {GetStackOrder(c)}; --fr-grid-span: {desktopSpan}; --fr-tablet-span: {tabletSpan};{visibilityStyle}"); + } + + private readonly record struct ControlAnchors(bool Left, bool Top, bool Right, bool Bottom); + + private ControlAnchors GetControlAnchors(ControlDefinition c) + { + bool left = GetBoolProp(c, "anchorLeft", fallback: true); + bool top = GetBoolProp(c, "anchorTop", fallback: true); + bool right = GetBoolProp(c, "anchorRight", fallback: false); + bool bottom = GetBoolProp(c, "anchorBottom", fallback: false); + + if (!left && !right) + left = true; + if (!top && !bottom) + top = true; + + return new ControlAnchors(left, top, right, bottom); + } + + private double GetAnchorCanvasWidth() + => AnchorCanvasWidth > 0 ? AnchorCanvasWidth : DesktopCanvasWidth; + + private double GetAnchorCanvasHeight() + => AnchorCanvasHeight is > 0 ? AnchorCanvasHeight.Value : GetCanvasHeight(); + + private static string ToCssPx(double value) + => FormattableString.Invariant($"{value}px"); + + private static string ToCssPercent(double value, double total) + { + if (total <= 0) + return "0%"; + + return FormattableString.Invariant($"{value / total * 100}%"); + } + + private double GetCanvasHeight() + { + ControlDefinition[] controls = GetControlsToRender().ToArray(); + double bottom = controls.Length == 0 + ? 500 + : controls.Max(control => control.Rect.Y + control.Rect.Height) + 24; + return Math.Max(500, bottom); + } + + private int GetStackOrder(ControlDefinition c) + { + var ordered = GetControlsToRender() + .Select((control, index) => new + { + Control = control, + Index = index, + Rect = GetEffectiveRect(control, "mobile"), + }) + .OrderBy(item => item.Rect.Y) + .ThenBy(item => item.Rect.X) + .ThenBy(item => item.Index) + .ToList(); + + int order = ordered.FindIndex(item => string.Equals(item.Control.ControlId, c.ControlId, StringComparison.Ordinal)); + return order < 0 ? 0 : order; + } + + private static int GetGridSpan(Rect rect, int columns, double canvasWidth) + { + if (canvasWidth <= 0) + return columns; + + int span = (int)Math.Ceiling(Math.Max(1, rect.Width) / canvasWidth * columns); + return Math.Clamp(span, 1, columns); + } + + private static Rect GetEffectiveRect(ControlDefinition c, string breakpoint) + { + if (string.Equals(breakpoint, "desktop", StringComparison.OrdinalIgnoreCase) || c.RendererHints is null) + return c.Rect; + + string key = $"bp:{breakpoint}"; + if (!c.RendererHints.TryGetValue(key, out object? hintObj) || hintObj is null) + return c.Rect; + + if (hintObj is JsonElement json && json.ValueKind == JsonValueKind.Object) + { + double x = GetJsonDouble(json, "x", c.Rect.X); + double y = GetJsonDouble(json, "y", c.Rect.Y); + double width = GetJsonDouble(json, "width", c.Rect.Width); + double height = GetJsonDouble(json, "height", c.Rect.Height); + return new Rect(x, y, width, height); + } + + if (hintObj is IReadOnlyDictionary dict) + { + double x = GetDictionaryDouble(dict, "x", c.Rect.X); + double y = GetDictionaryDouble(dict, "y", c.Rect.Y); + double width = GetDictionaryDouble(dict, "width", c.Rect.Width); + double height = GetDictionaryDouble(dict, "height", c.Rect.Height); + return new Rect(x, y, width, height); + } + + return c.Rect; + } + + private static bool IsVisibleAtBreakpoint(ControlDefinition c, string breakpoint) + { + if (string.Equals(breakpoint, "desktop", StringComparison.OrdinalIgnoreCase) || c.RendererHints is null) + return true; + + string key = $"bp:{breakpoint}"; + if (!c.RendererHints.TryGetValue(key, out object? hintObj) || hintObj is null) + return true; + + if (hintObj is JsonElement json && json.ValueKind == JsonValueKind.Object) + return !json.TryGetProperty("visible", out JsonElement visible) || visible.GetBoolean(); + + if (hintObj is IReadOnlyDictionary dict && dict.TryGetValue("visible", out object? value)) + return ReadBoolean(value, fallback: true); + + return true; + } + + private static double GetJsonDouble(JsonElement json, string propertyName, double fallback) + => json.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.Number && property.TryGetDouble(out double value) + ? value + : fallback; + + private static double GetDictionaryDouble(IReadOnlyDictionary dict, string key, double fallback) + { + if (!dict.TryGetValue(key, out object? value) || value is null) + return fallback; + + return value switch + { + double d => d, + float f => f, + decimal m => (double)m, + int i => i, + long l => l, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetDouble(out double d) => d, + _ when double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) => parsed, + _ => fallback, + }; + } + + private static bool ReadBoolean(object? value, bool fallback) + { + return value switch + { + bool b => b, + JsonElement json when json.ValueKind is JsonValueKind.True or JsonValueKind.False => json.GetBoolean(), + _ when bool.TryParse(value?.ToString(), out bool parsed) => parsed, + _ => fallback, + }; + } private string GetFieldValue(string? fieldName) { @@ -228,25 +797,827 @@ private bool IsRadioChoiceSelected(string? fieldName, string? choiceValue) => FormControlValueConverter.ChoiceMatchesValue(GetFieldObjectValue(fieldName), choiceValue, GetFieldDefinition(fieldName)); - private void SetCheckboxFieldValue(string? fieldName, bool isChecked) - => SetFieldValue(fieldName, FormControlValueConverter.ConvertCheckboxValue(isChecked, GetFieldDefinition(fieldName))); + private IReadOnlyList GetControlChoices(ControlDefinition control, string? fieldName) + => FormChoiceResolver.ResolveChoices(control, fieldName, Choices, GetFieldDefinition(fieldName)); + + private string GetSelectedChoiceValue(string? fieldName, IReadOnlyList choices) + { + object? value = GetFieldObjectValue(fieldName); + if (value is null) + return string.Empty; + + EnumChoice? selected = choices.FirstOrDefault(choice => + FormControlValueConverter.ChoiceMatchesValue(value, choice.Value, GetFieldDefinition(fieldName))); + return selected?.Value ?? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } + + private string GetComboDisplayValue(string? fieldName, IReadOnlyList choices) + { + string selectedValue = GetSelectedChoiceValue(fieldName, choices); + if (string.IsNullOrWhiteSpace(selectedValue)) + return GetFieldValue(fieldName); + + return choices.FirstOrDefault(choice => string.Equals(choice.Value, selectedValue, StringComparison.OrdinalIgnoreCase))?.Label + ?? selectedValue; + } + + private Task SetCheckboxFieldValueAsync(ControlDefinition control, string? fieldName, bool isChecked) + => SetFieldValueAsync(control, fieldName, FormControlValueConverter.ConvertCheckboxValue(isChecked, GetFieldDefinition(fieldName))); + + private Task SetRadioFieldValueAsync(ControlDefinition control, string? fieldName, string? choiceValue) + => SetFieldValueAsync(control, fieldName, FormControlValueConverter.ConvertChoiceValue(choiceValue, GetFieldDefinition(fieldName))); + + private Task SetChoiceFieldValueAsync(ControlDefinition control, string? fieldName, object? choiceValue) + => SetFieldValueAsync(control, fieldName, FormControlValueConverter.ConvertChoiceValue(choiceValue?.ToString(), GetFieldDefinition(fieldName))); - private void SetRadioFieldValue(string? fieldName, string? choiceValue) - => SetFieldValue(fieldName, FormControlValueConverter.ConvertChoiceValue(choiceValue, GetFieldDefinition(fieldName))); + private bool IsListBoxMultiSelect(ControlDefinition control) + => GetBoolProp(control, "multiSelect"); - private void SetFieldValue(string? fieldName, object? value) + private bool IsListBoxChoiceSelected(ControlDefinition control, string? fieldName, string? choiceValue, IReadOnlyList choices) + { + if (!IsListBoxMultiSelect(control)) + return FormControlValueConverter.ChoiceMatchesValue(GetFieldObjectValue(fieldName), choiceValue, GetFieldDefinition(fieldName)); + + FormFieldDefinition? field = GetFieldDefinition(fieldName); + return GetSelectedListBoxValues(control, fieldName, choices) + .Any(selected => FormControlValueConverter.ChoiceMatchesValue(selected, choiceValue, field)); + } + + private Task SetListBoxValueAsync(ControlDefinition control, string? fieldName, object? rawValue, IReadOnlyList choices) + { + if (!IsListBoxMultiSelect(control)) + return SetChoiceFieldValueAsync(control, fieldName, rawValue); + + IReadOnlyList selectedValues = ReadSelectedListBoxValues(rawValue); + object? storedValue = selectedValues.Count == 0 + ? null + : string.Join(GetListBoxValueDelimiter(control), selectedValues); + + return SetFieldValueAsync(control, fieldName, storedValue); + } + + private IReadOnlyList GetSelectedListBoxValues(ControlDefinition control, string? fieldName, IReadOnlyList choices) + { + object? value = GetFieldObjectValue(fieldName); + if (value is null) + return []; + + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Array) + { + return json.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .ToArray(); + } + + if (json.ValueKind == JsonValueKind.String) + value = json.GetString(); + } + + if (value is string text) + { + string delimiter = GetListBoxValueDelimiter(control); + return text + .Split([delimiter], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + } + + if (value is System.Collections.IEnumerable values && value is not byte[]) + { + return values + .Cast() + .Select(item => Convert.ToString(item, CultureInfo.InvariantCulture)) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .ToArray(); + } + + string selectedValue = GetSelectedChoiceValue(fieldName, choices); + return string.IsNullOrWhiteSpace(selectedValue) ? [] : [selectedValue]; + } + + private static IReadOnlyList ReadSelectedListBoxValues(object? rawValue) + { + if (rawValue is null) + return []; + + if (rawValue is string text) + return string.IsNullOrWhiteSpace(text) ? [] : [text]; + + if (rawValue is string[] textValues) + return textValues.Where(value => !string.IsNullOrWhiteSpace(value)).ToArray(); + + if (rawValue is IEnumerable enumerableTextValues) + return enumerableTextValues.Where(value => !string.IsNullOrWhiteSpace(value)).ToArray(); + + if (rawValue is System.Collections.IEnumerable enumerable) + { + return enumerable + .Cast() + .Select(item => Convert.ToString(item, CultureInfo.InvariantCulture)) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .ToArray(); + } + + string converted = Convert.ToString(rawValue, CultureInfo.InvariantCulture) ?? string.Empty; + return string.IsNullOrWhiteSpace(converted) ? [] : [converted]; + } + + private string GetListBoxValueDelimiter(ControlDefinition control) + { + string delimiter = GetProp(control, "multiValueDelimiter", ";"); + return string.IsNullOrEmpty(delimiter) ? ";" : delimiter; + } + + private Task SetComboBoxValueAsync(ControlDefinition control, string? fieldName, object? inputValue, IReadOnlyList choices) + { + string raw = inputValue?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(raw)) + return SetFieldValueAsync(control, fieldName, null); + + EnumChoice? choice = choices.FirstOrDefault(candidate => + string.Equals(candidate.Label, raw, StringComparison.OrdinalIgnoreCase) || + string.Equals(candidate.Value, raw, StringComparison.OrdinalIgnoreCase)); + + if (choice is not null) + return SetChoiceFieldValueAsync(control, fieldName, choice.Value); + + bool allowCustomValue = GetBoolProp(control, "allowCustomValue"); + return allowCustomValue + ? SetChoiceFieldValueAsync(control, fieldName, raw) + : Task.CompletedTask; + } + + private static string GetOptionGroupClass(string orientation, bool buttonStyle) + { + List classes = ["fr-option-group"]; + classes.Add(string.Equals(orientation, "horizontal", StringComparison.OrdinalIgnoreCase) + ? "fr-option-horizontal" + : "fr-option-vertical"); + if (buttonStyle) + classes.Add("fr-option-buttons"); + return string.Join(" ", classes); + } + + private bool IsToggleButtonOn(ControlDefinition control, string? fieldName) + { + object? value = GetFieldObjectValue(fieldName); + object? trueValue = GetConfiguredToggleValue(control, "trueValue", true); + return ValuesMatch(value, trueValue, GetFieldDefinition(fieldName)); + } + + private string GetToggleButtonClass(bool isOn) + => isOn ? "fr-toggle-button active" : "fr-toggle-button"; + + private string GetToggleButtonText(ControlDefinition control, string? fieldName, bool isOn) + { + string explicitText = GetProp(control, "text", string.Empty); + if (!string.IsNullOrWhiteSpace(explicitText)) + return explicitText; + + return isOn + ? GetProp(control, "onText", fieldName ?? "On") + : GetProp(control, "offText", fieldName ?? "Off"); + } + + private Task ToggleButtonValueAsync(ControlDefinition control, string? fieldName) + { + bool isOn = IsToggleButtonOn(control, fieldName); + object? next = GetConfiguredToggleValue(control, isOn ? "falseValue" : "trueValue", isOn ? false : true); + FormFieldDefinition? field = GetFieldDefinition(fieldName); + object? converted = field?.DataType == FieldDataType.Boolean + ? FormControlValueConverter.ConvertCheckboxValue(FormControlValueConverter.ToBoolean(next), field) + : FormControlValueConverter.ConvertChoiceValue(next?.ToString(), field); + return SetFieldValueAsync(control, fieldName, converted); + } + + private static object? GetConfiguredToggleValue(ControlDefinition control, string key, object fallback) + { + if (!control.Props.Values.TryGetValue(key, out object? value) || value is null) + return fallback; + + if (value is JsonElement json) + return json.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => json.GetString(), + JsonValueKind.Number when json.TryGetInt64(out long integer) => integer, + JsonValueKind.Number => json.GetDouble(), + _ => json.ToString(), + }; + + return value; + } + + private static bool ValuesMatch(object? value, object? configuredValue, FormFieldDefinition? field) + { + if (configuredValue is bool boolValue) + return FormControlValueConverter.ToBoolean(value) == boolValue; + + return FormControlValueConverter.ChoiceMatchesValue(value, configuredValue?.ToString(), field); + } + + private async Task SetFieldValueAsync(ControlDefinition control, string? fieldName, object? value) { if (fieldName is null) return; - BeforeFieldChanged.InvokeAsync(fieldName); + object? oldValue = GetFieldObjectValue(fieldName); + await BeforeFieldChanged.InvokeAsync(fieldName); + Record[fieldName] = value; + await InvokeControlEventsAsync( + control, + ControlEventKind.OnChange, + BuildFieldEventArguments(control, ControlEventKind.OnChange, fieldName, value, oldValue)); + await OnFieldChanged.InvokeAsync(fieldName); + } + + private async Task OnBlobFileChangedAsync(ControlDefinition control, string? fieldName, InputFileChangeEventArgs args) + { + if ((!IsAttachmentTableMode(control) && string.IsNullOrWhiteSpace(fieldName)) || !IsControlEnabled(control) || IsControlReadOnly(control)) + return; + + IBrowserFile file = args.File; + long maxBytes = GetLongProp(control, "maxFileBytes", DefaultMaxUploadBytes); + await using Stream stream = file.OpenReadStream(maxBytes); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + string eventFieldName = fieldName ?? FormAttachmentValue.GetRecordKey(control.ControlId); + + object? oldValue = IsAttachmentTableMode(control) + ? GetPendingAttachmentValue(control) + : GetFieldObjectValue(fieldName); + await BeforeFieldChanged.InvokeAsync(eventFieldName); + + if (IsAttachmentTableMode(control)) + { + Record[FormAttachmentValue.GetRecordKey(control.ControlId)] = FormAttachmentValue.FromFile( + memory.ToArray(), + file.Name, + file.ContentType, + file.Size); + } + else + { + Record[fieldName!] = memory.ToArray(); + await SetRelatedFieldValueAsync(control, "fileNameField", file.Name); + await SetRelatedFieldValueAsync(control, "contentTypeField", file.ContentType); + await SetRelatedFieldValueAsync(control, "fileSizeField", file.Size); + } + + object? newValue = IsAttachmentTableMode(control) + ? GetPendingAttachmentValue(control) + : Record[fieldName!]; + var runtimeArguments = BuildFieldEventArguments(control, ControlEventKind.OnChange, eventFieldName, newValue, oldValue); + runtimeArguments["fileName"] = file.Name; + runtimeArguments["contentType"] = file.ContentType; + runtimeArguments["fileSize"] = file.Size; + + await InvokeControlEventsAsync(control, ControlEventKind.OnChange, runtimeArguments); + await OnFieldChanged.InvokeAsync(eventFieldName); + } + + private async Task ClearBlobFieldAsync(ControlDefinition control, string? fieldName) + { + if ((!IsAttachmentTableMode(control) && string.IsNullOrWhiteSpace(fieldName)) || !CanClearBlob(control, fieldName)) + return; + + string eventFieldName = fieldName ?? FormAttachmentValue.GetRecordKey(control.ControlId); + object? oldValue = IsAttachmentTableMode(control) + ? GetPendingAttachmentValue(control) + : GetFieldObjectValue(fieldName); + await BeforeFieldChanged.InvokeAsync(eventFieldName); + + if (IsAttachmentTableMode(control)) + { + Record[FormAttachmentValue.GetRecordKey(control.ControlId)] = FormAttachmentValue.Clear(); + } + else + { + Record[fieldName!] = null; + await SetRelatedFieldValueAsync(control, "fileNameField", null); + await SetRelatedFieldValueAsync(control, "contentTypeField", null); + await SetRelatedFieldValueAsync(control, "fileSizeField", null); + } + + await InvokeControlEventsAsync( + control, + ControlEventKind.OnChange, + BuildFieldEventArguments(control, ControlEventKind.OnChange, eventFieldName, IsAttachmentTableMode(control) ? GetPendingAttachmentValue(control) : null, oldValue)); + await OnFieldChanged.InvokeAsync(eventFieldName); + } + + private Task SetRelatedFieldValueAsync(ControlDefinition control, string metadataProperty, object? value) + { + string fieldName = GetProp(control, metadataProperty, string.Empty); + if (string.IsNullOrWhiteSpace(fieldName)) + return Task.CompletedTask; + Record[fieldName] = value; - OnFieldChanged.InvokeAsync(fieldName); + return OnFieldChanged.InvokeAsync(fieldName); + } + + private bool CanClearBlob(ControlDefinition control, string? fieldName) + => IsControlEnabled(control) && + !IsControlReadOnly(control) && + (IsAttachmentTableMode(control) || GetBlobBytes(fieldName) is { Length: > 0 }); + + private string GetBlobSummary(ControlDefinition control, string? fieldName) + { + if (IsAttachmentTableMode(control)) + { + FormAttachmentValue? pending = GetPendingAttachmentValue(control); + if (pending?.ClearExisting == true) + return "Attachment will be cleared"; + if (pending?.Bytes is { Length: > 0 } pendingBytes) + { + string pendingSize = FormatByteSize(pendingBytes.LongLength); + return string.IsNullOrWhiteSpace(pending.FileName) + ? $"Pending upload ({pendingSize})" + : $"Pending upload: {pending.FileName} ({pendingSize})"; + } + + return "Attachment table"; + } + + byte[]? bytes = GetBlobBytes(fieldName); + if (bytes is null || bytes.Length == 0) + return "No file"; + + string fileNameField = GetProp(control, "fileNameField", string.Empty); + string fileName = string.IsNullOrWhiteSpace(fileNameField) ? string.Empty : GetFieldValue(fileNameField); + string size = FormatByteSize(bytes.LongLength); + return string.IsNullOrWhiteSpace(fileName) + ? size + : $"{fileName} ({size})"; + } + + private byte[]? GetBlobBytes(string? fieldName) + { + object? value = GetFieldObjectValue(fieldName); + return value switch + { + byte[] bytes => bytes, + JsonElement json when json.ValueKind == JsonValueKind.String && TryReadBase64(json.GetString(), out byte[]? bytes) => bytes, + string text when TryReadBase64(text, out byte[]? bytes) => bytes, + _ => null, + }; + } + + private string? GetImageDataUrl(ControlDefinition control, string? fieldName) + { + if (IsAttachmentTableMode(control) && GetPendingAttachmentValue(control)?.Bytes is { Length: > 0 } pendingBytes) + { + string pendingContentType = GetPendingAttachmentValue(control)?.ContentType ?? "image/png"; + if (string.IsNullOrWhiteSpace(pendingContentType)) + pendingContentType = "image/png"; + + return $"data:{pendingContentType};base64,{Convert.ToBase64String(pendingBytes)}"; + } + + byte[]? bytes = GetBlobBytes(fieldName); + if (bytes is null || bytes.Length == 0) + return null; + + string contentTypeField = GetProp(control, "contentTypeField", string.Empty); + string contentType = string.IsNullOrWhiteSpace(contentTypeField) ? string.Empty : GetFieldValue(contentTypeField); + if (string.IsNullOrWhiteSpace(contentType)) + contentType = "image/png"; + + return $"data:{contentType};base64,{Convert.ToBase64String(bytes)}"; + } + + private bool IsAttachmentTableMode(ControlDefinition control) + => string.Equals(GetProp(control, "storageMode", "blobField"), "attachmentTable", StringComparison.OrdinalIgnoreCase); + + private FormAttachmentValue? GetPendingAttachmentValue(ControlDefinition control) + => Record.TryGetValue(FormAttachmentValue.GetRecordKey(control.ControlId), out object? value) && value is FormAttachmentValue attachment + ? attachment + : null; + + private static bool TryReadBase64(string? value, out byte[]? bytes) + { + bytes = null; + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + bytes = Convert.FromBase64String(value); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static string FormatByteSize(long bytes) + { + string[] units = ["B", "KB", "MB", "GB"]; + double size = bytes; + int unit = 0; + while (size >= 1024 && unit < units.Length - 1) + { + size /= 1024; + unit++; + } + + return unit == 0 + ? $"{bytes} {units[unit]}" + : $"{size:0.#} {units[unit]}"; + } + + private string GetImageStyle(ControlDefinition control) + { + string fit = GetProp(control, "fit", "contain"); + if (fit is not ("contain" or "cover" or "fill" or "scale-down")) + fit = "contain"; + + return $"object-fit: {fit};"; + } + + private async Task InvokeCommandButtonAsync(ControlDefinition control) + { + string commandName = GetProp(control, "commandName", string.Empty); + if (string.IsNullOrWhiteSpace(commandName) && !HasControlEventBindings(control, ControlEventKind.OnClick)) + { + await ReportCommandErrorAsync("Command button has no command name."); + return; + } + + DbCommandDefinition? definition = null; + if (!string.IsNullOrWhiteSpace(commandName) && !Commands.TryGetCommand(commandName, out definition)) + { + Dictionary metadata = FormCommandInvocation.BuildControlMetadata(Form, control, "Click"); + string message = $"Unknown form command '{commandName}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(commandName, metadata, message); + await ReportCommandErrorAsync(message); + return; + } + + _executingCommandButtons.Add(control.ControlId); + await RefreshCommandButtonStateAsync(); + try + { + if (!string.IsNullOrWhiteSpace(commandName)) + { + if (definition is null) + throw new InvalidOperationException($"Form command '{commandName}' was not resolved."); + + control.Props.Values.TryGetValue("commandArguments", out object? configuredArguments); + Dictionary arguments = FormCommandInvocation.BuildArguments( + Record, + FormCommandInvocation.ReadArgumentsProperty(configuredArguments)); + Dictionary metadata = FormCommandInvocation.BuildControlMetadata(Form, control, "Click"); + + DbCommandResult result = await definition.InvokeAsync( + arguments, + metadata, + EffectiveCallbackPolicy, + DbExtensionHostMode.Embedded); + if (!result.Succeeded) + { + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Form command '{definition.Name}' failed." + : result.Message; + await ReportCommandErrorAsync(message); + return; + } + } + + await InvokeControlEventsAsync(control, ControlEventKind.OnClick); + } + catch (Exception ex) + { + string subject = string.IsNullOrWhiteSpace(commandName) + ? "Control click command" + : $"Form command '{commandName}'"; + await ReportCommandErrorAsync($"{subject} failed: {ex.Message}"); + } + finally + { + _executingCommandButtons.Remove(control.ControlId); + await RefreshCommandButtonStateAsync(); + } + } + + private async Task RefreshCommandButtonStateAsync() + { + try + { + await InvokeAsync(StateHasChanged); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("render handle", StringComparison.OrdinalIgnoreCase)) + { + // Private unit-test invocations can run before Blazor assigns a render handle. + } + } + + private Task InvokeFieldControlEventAsync(ControlDefinition control, ControlEventKind eventKind, string? fieldName) + => InvokeControlEventsAsync( + control, + eventKind, + BuildFieldEventArguments(control, eventKind, fieldName, GetFieldObjectValue(fieldName), oldValue: null)); + + private async Task InvokeControlEventsAsync( + ControlDefinition control, + ControlEventKind eventKind, + IReadOnlyDictionary? runtimeArguments = null) + { + IReadOnlyList bindings = control.EventBindings ?? []; + foreach (ControlEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) + { + Dictionary metadata = FormCommandInvocation.BuildControlMetadata(Form, control, eventKind.ToString()); + + if (!string.IsNullOrWhiteSpace(binding.CommandName)) + { + if (!Commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) + { + string message = $"Unknown form command '{binding.CommandName}' for control event '{eventKind}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(binding.CommandName, metadata, message); + await ReportCommandErrorAsync(message); + return; + } + + Dictionary arguments = FormCommandInvocation.BuildArguments( + Record, + runtimeArguments, + binding.Arguments); + + bool commandFailed = false; + string? commandFailureMessage = null; + try + { + DbCommandResult result = await definition.InvokeAsync( + arguments, + metadata, + EffectiveCallbackPolicy, + DbExtensionHostMode.Embedded); + if (!result.Succeeded) + { + commandFailed = true; + commandFailureMessage = string.IsNullOrWhiteSpace(result.Message) + ? $"Control event '{eventKind}' command '{definition.Name}' failed." + : result.Message; + } + } + catch (Exception ex) + { + commandFailed = true; + commandFailureMessage = $"Control event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"; + } + + if (commandFailed) + { + if (binding.StopOnFailure) + { + await ReportCommandErrorAsync(commandFailureMessage!); + return; + } + + if (binding.ActionSequence is null) + continue; + } + } + else if (binding.ActionSequence is null) + { + await ReportCommandErrorAsync($"Control event '{eventKind}' has no command or action sequence."); + return; + } + + if (binding.ActionSequence is not null) + { + FormEventDispatchResult actionResult = await FormActionSequenceExecutor.ExecuteAsync( + binding.ActionSequence, + Commands, + Record, + binding.Arguments, + runtimeArguments, + metadata, + reusableSequences: Form.ActionSequences, + setFieldValue: SetActionFieldValueAsync, + showMessage: ReportCommandErrorAsync, + executeBuiltInFormAction: OnBuiltInAction, + actionRuntime: ActionRuntime, + callbackPolicy: EffectiveCallbackPolicy); + + if (!actionResult.Succeeded && binding.StopOnFailure) + { + await ReportCommandErrorAsync(actionResult.Message ?? $"Control event '{eventKind}' action sequence failed."); + return; + } + } + } + } + + private static bool HasControlEventBindings(ControlDefinition control, ControlEventKind eventKind) + => control.EventBindings?.Any(binding => binding.Event == eventKind) == true; + + private static Dictionary BuildFieldEventArguments( + ControlDefinition control, + ControlEventKind eventKind, + string? fieldName, + object? value, + object? oldValue) + { + var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["controlId"] = control.ControlId, + ["controlType"] = control.ControlType, + ["event"] = eventKind.ToString(), + }; + + if (!string.IsNullOrWhiteSpace(fieldName)) + arguments["fieldName"] = fieldName; + + if (value is not null) + arguments["value"] = value; + + if (oldValue is not null) + arguments["oldValue"] = oldValue; + + return arguments; + } + + private Task ReportCommandErrorAsync(string message) + => OnCommandError.HasDelegate ? OnCommandError.InvokeAsync(message) : Task.CompletedTask; + + private async Task SetActionFieldValueAsync(string fieldName, object? value) + { + if (string.IsNullOrWhiteSpace(fieldName)) + return; + + string key = Record.Keys.FirstOrDefault(candidate => string.Equals(candidate, fieldName, StringComparison.OrdinalIgnoreCase)) + ?? fieldName; + await BeforeFieldChanged.InvokeAsync(key); + Record[key] = value; + await OnFieldChanged.InvokeAsync(key); + } + + private IReadOnlyList GetTabPages(ControlDefinition control) + { + if (!control.Props.Values.TryGetValue("tabs", out object? value) || value is null) + return []; + + if (value is JsonElement json && json.ValueKind == JsonValueKind.Array) + { + return json.EnumerateArray() + .Select(element => ReadTabPage(element)) + .Where(tab => tab is not null) + .Select(tab => tab!) + .ToArray(); + } + + if (value is IEnumerable items) + { + return items + .Select(item => ReadTabPage(item)) + .Where(tab => tab is not null) + .Select(tab => tab!) + .ToArray(); + } + + return []; + } + + private static TabPageInfo? ReadTabPage(object? value) + { + if (value is null) + return null; + + if (value is JsonElement json) + { + if (json.ValueKind != JsonValueKind.Object) + return null; + + string id = json.TryGetProperty("id", out JsonElement idElement) ? ReadJsonText(idElement) : string.Empty; + string label = json.TryGetProperty("label", out JsonElement labelElement) ? ReadJsonText(labelElement) : id; + return string.IsNullOrWhiteSpace(id) ? null : new TabPageInfo(id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + if (value is IReadOnlyDictionary readOnly) + return ReadTabPage(readOnly); + + if (value is IDictionary dictionary) + return ReadTabPage(dictionary); + + return null; + } + + private static TabPageInfo? ReadTabPage(IReadOnlyDictionary dictionary) + { + string id = ReadDictionaryText(dictionary, "id"); + if (string.IsNullOrWhiteSpace(id)) + return null; + + string label = ReadDictionaryText(dictionary, "label"); + return new TabPageInfo(id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + private TabPageInfo GetActiveTabPage(ControlDefinition control, IReadOnlyList tabs) + { + if (_activeTabs.TryGetValue(control.ControlId, out string? activeId)) + { + TabPageInfo? active = tabs.FirstOrDefault(tab => string.Equals(tab.Id, activeId, StringComparison.Ordinal)); + if (active is not null) + return active; + } + + _activeTabs[control.ControlId] = tabs[0].Id; + return tabs[0]; + } + + private void SetActiveTab(string controlId, string tabId) + => _activeTabs[controlId] = tabId; + + private string GetTabButtonClass(string activeTabId, string tabId) + => string.Equals(activeTabId, tabId, StringComparison.Ordinal) + ? "fr-tab-button active" + : "fr-tab-button"; + + private FormDefinition BuildTabChildForm(ControlDefinition tabControl, string tabId) + { + var childControls = Form.Controls + .Where(control => IsTabChildControl(control, tabControl.ControlId, tabId)) + .ToArray(); + + return Form with { Controls = childControls }; } + private static double GetTabChildCanvasWidth(ControlDefinition tabControl) + => Math.Max(24, tabControl.Rect.Width - 24); + + private double GetTabChildCanvasHeight(ControlDefinition tabControl, string tabId) + { + double designHeight = Form.Controls + .Where(control => IsTabChildControl(control, tabControl.ControlId, tabId)) + .Select(control => control.Rect.Y + control.Rect.Height + 24) + .DefaultIfEmpty(0) + .Max(); + double tabPageHeight = Math.Max(16, tabControl.Rect.Height - 56); + + return Math.Max(designHeight, tabPageHeight); + } + + private static bool IsTabChildControl(ControlDefinition control, string parentControlId, string tabId) + => TryGetControlProp(control, "parentControlId", out string configuredParentId) + && string.Equals(configuredParentId, parentControlId, StringComparison.Ordinal) + && TryGetControlProp(control, "parentTabId", out string configuredTabId) + && string.Equals(configuredTabId, tabId, StringComparison.Ordinal); + + private static bool TryGetControlProp(ControlDefinition control, string key, out string value) + { + value = string.Empty; + if (!control.Props.Values.TryGetValue(key, out object? raw) || raw is null) + return false; + + if (raw is JsonElement json) + raw = json.ValueKind == JsonValueKind.String ? json.GetString() : json.ToString(); + + value = raw?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } + + private static string ReadDictionaryText(IReadOnlyDictionary dictionary, string key) + { + if (!dictionary.TryGetValue(key, out object? value) || value is null) + return string.Empty; + + if (value is JsonElement json) + return ReadJsonText(json); + + return value.ToString() ?? string.Empty; + } + + private static string ReadJsonText(JsonElement value) + => value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.ToString(); + + private string BuildSubformKey(ControlDefinition control, object? parentValue) + => $"{control.ControlId}:{parentValue}"; + + private static string BuildSubformFilterExpression(string foreignKeyField) + => $"[{foreignKeyField}] = @parentValue"; + + private static IReadOnlyDictionary BuildSubformFilterParameters(object? parentValue) + => new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["parentValue"] = parentValue, + }; + private FormFieldDefinition? GetFieldDefinition(string? fieldName) => fieldName is null ? null : TableDefinition?.Fields.FirstOrDefault(field => string.Equals(field.Name, fieldName, StringComparison.OrdinalIgnoreCase)); + private sealed record TabPageInfo(string Id, string Label); + private static int GetTabIndex(ControlDefinition c) { if (c.Props.Values.TryGetValue("tabIndex", out var val)) @@ -260,10 +1631,12 @@ return 0; } - private static string GetProp(ControlDefinition c, string key, string fallback) + private string GetProp(ControlDefinition c, string key, string fallback) { - if (c.Props.Values.TryGetValue(key, out var val) && val is string s) + if (TryGetEffectiveProperty(c, key, out object? val) && val is string s) return s; + if (val is not null && key is "text" or "placeholder") + return val.ToString() ?? fallback; return fallback; } @@ -279,11 +1652,127 @@ return []; } - private static bool GetBoolProp(ControlDefinition c, string key) + private static List GetDataGridVisibleColumns(ControlDefinition control, FormTableDefinition? tableDefinition) { - if (c.Props.Values.TryGetValue(key, out var val) && val is bool b) - return b; - return false; + List configuredColumns = GetListProp(control, "visibleColumns"); + if (configuredColumns.Count > 0 || tableDefinition is null) + return configuredColumns; + + return tableDefinition.Fields + .Select(field => field.Name) + .ToList(); + } + + private ControlFilterState? GetControlFilter(string controlId) + => ControlFilters?.TryGetValue(controlId, out ControlFilterState? filter) == true + ? filter + : null; + + private bool IsControlVisible(ControlDefinition c) + => GetBoolProp(c, "visible", fallback: true); + + private bool IsControlEnabled(ControlDefinition c) + => GetBoolProp(c, "enabled", fallback: true); + + private bool IsControlReadOnly(ControlDefinition c) + => GetBoolProp(c, "readOnly", fallback: false); + + private bool GetBoolProp(ControlDefinition c, string key, bool fallback = false) + => TryGetEffectiveProperty(c, key, out object? value) + ? ReadBoolean(value, fallback) + : fallback; + + private int GetIntProp(ControlDefinition c, string key, int fallback) + => TryGetEffectiveProperty(c, key, out object? value) + ? ReadInteger(value, fallback) + : fallback; + + private double GetDoubleProp(ControlDefinition c, string key, double fallback) + => TryGetEffectiveProperty(c, key, out object? value) + ? ReadDouble(value, fallback) + : fallback; + + private long GetLongProp(ControlDefinition c, string key, long fallback) + => TryGetEffectiveProperty(c, key, out object? value) + ? ReadLong(value, fallback) + : fallback; + + private static int ReadInteger(object? value, int fallback) + { + long parsed = ReadLong(value, fallback); + return parsed > int.MaxValue || parsed < int.MinValue + ? fallback + : (int)parsed; + } + + private static long ReadLong(object? value, long fallback) + { + return value switch + { + int i => i, + long l => l, + double d => (long)d, + decimal m => (long)m, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetInt64(out long l) => l, + _ when long.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed) => parsed, + _ => fallback, + }; + } + + private static double ReadDouble(object? value, double fallback) + { + return value switch + { + int i => i, + long l => l, + float f => f, + double d => d, + decimal m => (double)m, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetDouble(out double d) => d, + JsonElement json when json.ValueKind == JsonValueKind.String && double.TryParse(json.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) => parsed, + _ when double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) => parsed, + _ => fallback, + }; + } + + private bool TryGetEffectiveProperty(ControlDefinition control, string key, out object? value) + { + bool found = control.Props.Values.TryGetValue(key, out value); + + foreach (ControlRuleDefinition rule in Form.Rules ?? []) + { + if (!FormActionConditionEvaluator.TryEvaluate( + rule.Condition, + Record, + bindingArguments: null, + runtimeArguments: null, + stepArguments: null, + out bool shouldApply, + out _) || + !shouldApply) + { + continue; + } + + foreach (ControlRuleEffect effect in rule.Effects) + { + if (string.Equals(effect.ControlId, control.ControlId, StringComparison.OrdinalIgnoreCase) && + string.Equals(effect.Property, key, StringComparison.OrdinalIgnoreCase)) + { + value = effect.Value; + found = true; + } + } + } + + if (ControlPropertyOverrides?.TryGetValue(control.ControlId, out IReadOnlyDictionary? overrides) == true && + overrides.TryGetValue(key, out object? overrideValue)) + { + value = overrideValue; + found = true; + } + + return found; } private static object? ParseNumber(object? value) diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/LayersPanel.razor b/src/CSharpDB.Admin.Forms/Components/Designer/LayersPanel.razor index d3e57a54..09cb5ece 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/LayersPanel.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/LayersPanel.razor @@ -1,4 +1,6 @@ @using CSharpDB.Admin.Forms.Components.Designer +@using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Models @implements IDisposable
@@ -36,6 +38,7 @@ @code { [CascadingParameter] public DesignerState State { get; set; } = default!; + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; private int _dragStartIndex = -1; private int _dragOverIndex = -1; @@ -76,32 +79,27 @@ State.SelectControl(controlId, e.ShiftKey); } - private static string GetLabel(CSharpDB.Admin.Forms.Models.ControlDefinition c) + private string GetLabel(ControlDefinition c) { // Prefer binding field name, then text prop, then control type if (c.Binding?.FieldName is { Length: > 0 } fieldName) return fieldName; if (c.Props.Values.TryGetValue("text", out var textVal) && textVal is string s && s.Length > 0) return s; - return c.ControlType; + return GetControlDisplayName(c.ControlType); } - private static string GetTypeIcon(string controlType) => controlType switch + private string GetTypeIcon(string controlType) { - "label" => "A", - "text" => "\u2328", - "textarea" => "\u2263", - "number" => "#", - "date" => "\U0001F4C5", - "checkbox" => "\u2611", - "radio" => "\u25C9", - "select" => "\u25BE", - "lookup" => "\U0001F50D", - "datagrid" => "\u2637", - "childtabs" => "\u2630", - "computed" => "\u03A3", - _ => "?" - }; + return ControlRegistry.TryGetControl(controlType, out FormControlDescriptor descriptor) + ? descriptor.IconText + : "?"; + } + + private string GetControlDisplayName(string controlType) + => ControlRegistry.TryGetControl(controlType, out FormControlDescriptor descriptor) + ? descriptor.DisplayName + : controlType; private static string LayerRowClass(bool selected, bool dragOver) { diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/MacroValidationPanel.razor b/src/CSharpDB.Admin.Forms/Components/Designer/MacroValidationPanel.razor new file mode 100644 index 00000000..bc2ed172 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/MacroValidationPanel.razor @@ -0,0 +1,45 @@ +@using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Services + +@if (_issues.Count > 0) +{ +
+ @GetSummaryText() +
    + @foreach (FormActionValidationIssue issue in _issues.Take(6)) + { +
  • @issue.Message
  • + } +
+
+} + +@code { + [Parameter, EditorRequired] public FormDefinition Form { get; set; } = default!; + [Parameter] public FormTableDefinition? Schema { get; set; } + + private IReadOnlyList _issues = []; + + protected override void OnParametersSet() + { + FormActionValidationResult result = FormActionManifestValidator.Validate( + Form, + new FormActionValidationOptions( + RuntimeCapabilities: FormActionRuntimeCapabilities.RenderedForm, + Schema: Schema)); + _issues = result.Issues; + } + + private string GetSummaryText() + { + int errors = _issues.Count(issue => issue.Severity == FormActionValidationSeverity.Error); + int warnings = _issues.Count - errors; + if (errors > 0 && warnings > 0) + return $"{errors} macro error(s), {warnings} warning(s)"; + + return errors > 0 + ? $"{errors} macro error(s)" + : $"{warnings} macro warning(s)"; + } +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index 63540d7b..ee78c33d 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -1,7 +1,13 @@ +@using System.Globalization +@using System.Text.Json @using CSharpDB.Admin.Forms.Models @using CSharpDB.Admin.Forms.Components.Designer @using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives @inject ISchemaProvider SchemaProvider +@inject DbCommandRegistry CommandRegistry +@inject IFormRepository FormRepository @implements IDisposable
@@ -16,9 +22,48 @@ } else { -

Select a control to edit its properties

+

Form properties

}
+ @if (State.SelectedIds.Count <= 1) + { +
+ +
+ + +
+
+ +
@(string.IsNullOrWhiteSpace(State.TableName) ? "No source selected" : State.TableName)
+
+
+ } +
+ + +
+
+ + +
+
+ + +
+
+ + +
} else { @@ -27,18 +72,14 @@
@@ -47,6 +88,13 @@
+
+ + +
+
@@ -73,9 +121,66 @@ @onchange="@(e => OnRectChanged(e, RectPart.H))" />
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
+
+
+ + +
+
+ + +
+
- @if (_selected.ControlType is "label" or "checkbox") + @if (_selected.ControlType is "label" or "checkbox" or "toggleButton") {
@@ -87,7 +192,7 @@
} - @if (_selected.ControlType is "text" or "textarea" or "number" or "select" or "lookup") + @if (_selected.ControlType is "text" or "textarea" or "number" or "select" or "lookup" or "comboBox" or "listBox") {
@@ -112,6 +217,85 @@
} + @if (_selected.ControlType is "select" or "comboBox" or "listBox" or "optionGroup") + { +
+ +
+ + + @if (!string.IsNullOrWhiteSpace(_optionsError)) + { +
@_optionsError
+ } +
+ @if (_selected.ControlType == "comboBox") + { +
+ + +
+ } + @if (_selected.ControlType == "listBox") + { +
+ + +
+ @if (GetBoolProp(PropMultiSelect)) + { +
+ + +
+ } +
+ + +
+ } + @if (_selected.ControlType == "optionGroup") + { +
+ + +
+
+ + +
+ } +
+ } + + @if (_selected.ControlType == "toggleButton") + { +
+ +
+ + +
+
+ + +
+
+ } + @if (_selected.Binding is not null) {
@@ -152,9 +336,25 @@ @onchange="@(e => OnPropChanged(PropTabIndex, ParseLong(e.Value)))" />
+
+ + +
+ } + else if (IsTabOrderControl(_selected)) + { +
+ +
+ + +
+
} - @if (_selected.ControlType == "lookup") + @if (_selected.ControlType is "lookup" or "comboBox" or "listBox" or "optionGroup") {
@@ -183,6 +383,12 @@ }
+
+ + +
} +
+ + +
} @@ -203,19 +414,135 @@
- +
+ + +
+ @if (_showFormulaHelper) + { +
+
+ + +
+ @if (_sourceTableDef?.Fields.Count > 0) + { +
+ @foreach (FormFieldDefinition field in _sourceTableDef.Fields.Where(static field => field.DataType != FieldDataType.Blob).Take(12)) + { + + } +
+ } + @foreach (FormulaFunctionGroup group in GetFormulaFunctionGroups()) + { +
+
@group.Category
+ @foreach (FormulaFunctionDescriptor function in group.Functions) + { +
+ + +
+ } +
+ } + @if (GetFormulaFunctionGroups().Count == 0) + { +
No matching functions.
+ } +
+ }
-
- Field: =A * B + C
- Aggregate: =SUM(Table.Field) +
+ Examples: =Nz(Quantity, 0) * Nz(UnitPrice, 0)
+ =DateDiff('d', OrderDate, Date()) +
+
+ } + + @if (_selected.ControlType == "commandButton") + { +
+ +
+ + +
+
+ + @if (RegisteredCommands.Count > 0) + { + + } + else + { + + } +
+
+ + + @if (!string.IsNullOrWhiteSpace(_commandArgumentError)) + { +
@_commandArgumentError
+ } +
+
+ +
} @@ -223,11 +550,18 @@ @if (_selected.ControlType == "datagrid") {
- + +
+ + +
+ + @foreach (var fk in fks) + { + var fkLabel = $"{fk.Name} ({string.Join(", ", fk.LocalFields)})"; + + } + +
+ } + else + { +
+ No FK referencing @State.TableName. Use manual mapping below. +
+ } +
- - + + @foreach (var field in _childTableDef.Fields) { - var fkLabel = $"{fk.Name} ({string.Join(", ", fk.LocalFields)})"; - + }
- } - else - { -
- No FK referencing @State.TableName. Use manual mapping below. -
- } - -
- - -
-
- - + + @if (_sourceTableDef is not null) { - + @foreach (var field in _sourceTableDef.Fields) + { + + } } - } - -
+ +
+ }
@@ -335,62 +672,359 @@
} -
- -
- - -
-
- - + @if (_selected.ControlType == "tabControl") + { +
+ +
+ + + @if (!string.IsNullOrWhiteSpace(_tabPagesError)) + { +
@_tabPagesError
+ } +
-
+ } - @if (State.ActiveBreakpoint != "desktop") + @if (_selected.ControlType != "tabControl" && GetTabControls().Count > 0) {
- +
- - + +
+ @if (GetSelectedParentTabControl() is { } parentTabControl) + { +
+ + +
+ }
} - } -
- -@code { - [CascadingParameter] public DesignerState State { get; set; } = default!; - private ControlDefinition? _selected; + @if (_selected.ControlType == "subform") + { +
+ +
+ + +
+
+ + @RenderFieldSelect(PropParentKeyField, _sourceTableDef, "-- Select parent field --") +
+
+ + +
+
+ + +
+
+ + +
+
+ } - // Property name constants to avoid string literals in Razor attributes - private const string PropText = "text"; - private const string PropPlaceholder = "placeholder"; - private const string PropMaxLength = "maxLength"; - private const string PropReadOnly = "readOnly"; - private const string PropTabIndex = "tabIndex"; - private const string PropChildTable = "childTable"; - private const string PropForeignKeyName = "foreignKeyName"; - private const string PropForeignKeyField = "foreignKeyField"; - private const string PropParentKeyField = "parentKeyField"; - private const string PropVisibleColumns = "visibleColumns"; - private const string PropAllowAdd = "allowAdd"; - private const string PropAllowEdit = "allowEdit"; - private const string PropAllowDelete = "allowDelete"; - private const string PropLookupTable = "lookupTable"; - private const string PropDisplayField = "displayField"; - private const string PropValueField = "valueField"; - private const string PropFormula = "formula"; - private const string PropFormat = "format"; + @if (_selected.ControlType is "attachment" or "image") + { +
+ +
+ + +
+ @if (IsAttachmentTableMode()) + { +
+ + +
+
+ + @RenderFieldSelect(PropParentKeyField, _sourceTableDef, "-- Primary key --") +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ } + else + { +
+ + @RenderFieldSelect(PropFileNameField, _sourceTableDef, "-- None --") +
+
+ + @RenderFieldSelect(PropContentTypeField, _sourceTableDef, "-- None --") +
+
+ + @RenderFieldSelect(PropFileSizeField, _sourceTableDef, "-- None --") +
+ } +
+ + +
+
+ + +
+ @if (_selected.ControlType == "image") + { +
+ + +
+
+ + +
+ } +
+ } + + @if (GetSelectedDescriptor() is { } registeredDescriptor && + (registeredDescriptor.PropertyEditorComponentType is not null || registeredDescriptor.PropertyDescriptors.Count > 0)) + { +
+ + @if (registeredDescriptor.PropertyEditorComponentType is not null) + { + + } + @foreach (FormControlPropertyDescriptor property in registeredDescriptor.PropertyDescriptors) + { +
+ + @switch (property.Editor) + { + case FormControlPropertyEditor.TextArea: + + break; + case FormControlPropertyEditor.Number: + + break; + case FormControlPropertyEditor.Checkbox: + + break; + case FormControlPropertyEditor.Select: + + break; + default: + + break; + } + @if (!string.IsNullOrWhiteSpace(property.HelpText)) + { +
@property.HelpText
+ } +
+ } +
+ } + +
+ +
+ + +
+
+ + +
+
+ + @if (State.ActiveBreakpoint != "desktop") + { +
+ +
+ + +
+
+ } + } +
+ +@code { + [CascadingParameter] public DesignerState State { get; set; } = default!; + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; + + private ControlDefinition? _selected; + + // Property name constants to avoid string literals in Razor attributes + private const string PropText = "text"; + private const string PropPlaceholder = "placeholder"; + private const string PropMaxLength = "maxLength"; + private const string PropReadOnly = "readOnly"; + private const string PropTabIndex = "tabIndex"; + private const string PropAnchorLeft = "anchorLeft"; + private const string PropAnchorTop = "anchorTop"; + private const string PropAnchorRight = "anchorRight"; + private const string PropAnchorBottom = "anchorBottom"; + private const string PropMinWidth = "minWidth"; + private const string PropMinHeight = "minHeight"; + private const string PropResizeMode = "resizeMode"; + private const string PropDataGridMode = "dataGridMode"; + private const string PropChildTable = "childTable"; + private const string PropForeignKeyName = "foreignKeyName"; + private const string PropForeignKeyField = "foreignKeyField"; + private const string PropParentKeyField = "parentKeyField"; + private const string PropVisibleColumns = "visibleColumns"; + private const string PropAllowAdd = "allowAdd"; + private const string PropAllowEdit = "allowEdit"; + private const string PropAllowDelete = "allowDelete"; + private const string PropLookupTable = "lookupTable"; + private const string PropDisplayField = "displayField"; + private const string PropValueField = "valueField"; + private const string PropDisplayFields = "displayFields"; + private const string PropRowLimit = "rowLimit"; + private const string PropOptions = "options"; + private const string PropAllowCustomValue = "allowCustomValue"; + private const string PropVisibleRows = "visibleRows"; + private const string PropMultiSelect = "multiSelect"; + private const string PropMultiValueDelimiter = "multiValueDelimiter"; + private const string PropOrientation = "orientation"; + private const string PropButtonStyle = "buttonStyle"; + private const string PropTrueValue = "trueValue"; + private const string PropFalseValue = "falseValue"; + private const string PropTabs = "tabs"; + private const string PropParentControlId = "parentControlId"; + private const string PropParentTabId = "parentTabId"; + private const string PropFormId = "formId"; + private const string PropShowToolbar = "showToolbar"; + private const string PropShowRecordList = "showRecordList"; + private const string PropFileNameField = "fileNameField"; + private const string PropContentTypeField = "contentTypeField"; + private const string PropFileSizeField = "fileSizeField"; + private const string PropStorageMode = "storageMode"; + private const string PropAttachmentTable = "attachmentTable"; + private const string PropAttachmentForeignKeyField = "attachmentForeignKeyField"; + private const string PropAttachmentBlobField = "attachmentBlobField"; + private const string PropAttachmentFileNameField = "attachmentFileNameField"; + private const string PropAttachmentContentTypeField = "attachmentContentTypeField"; + private const string PropAttachmentFileSizeField = "attachmentFileSizeField"; + private const string PropAttachmentControlIdField = "attachmentControlIdField"; + private const string PropAccept = "accept"; + private const string PropMaxFileBytes = "maxFileBytes"; + private const string PropAlt = "alt"; + private const string PropFit = "fit"; + private const string PropFormula = "formula"; + private const string PropFormat = "format"; + private const string PropCommandName = "commandName"; + private const string PropCommandArguments = "commandArguments"; private enum RectPart { X, Y, W, H } + private sealed record FormulaFunctionGroup(string Category, IReadOnlyList Functions); + // DataGrid configuration state private IReadOnlyList? _availableTables; + private IReadOnlyList? _availableForms; private string? _loadedSourceTableName; private FormTableDefinition? _sourceTableDef; private FormTableDefinition? _childTableDef; @@ -400,11 +1034,26 @@ // ChildTabs configuration state private List _currentTabs = []; + private string? _loadedCommandArgumentControlId; + private string _commandArgumentText = string.Empty; + private string? _commandArgumentError; + private string? _loadedOptionsControlId; + private string _optionsText = string.Empty; + private string? _optionsError; + private string? _loadedTabPagesControlId; + private string _tabPagesText = string.Empty; + private string? _tabPagesError; + private bool _showFormulaHelper; + private string _formulaFunctionQuery = string.Empty; + private string _formulaFunctionCategory = string.Empty; + + private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); protected override async Task OnInitializedAsync() { State.OnChange += OnStateChanged; _availableTables = await SchemaProvider.ListTableNamesAsync(); + _availableForms = await FormRepository.ListAsync(); await RefreshSourceTableDefinitionAsync(); } @@ -439,8 +1088,8 @@ await InvokeAsync(StateHasChanged); } - // Load lookup table def when a lookup control is selected - if (_selected?.ControlType == "lookup" && _selected != prev) + // Load lookup table def when a lookup-backed choice control is selected + if (_selected != prev && IsLookupChoiceControl(_selected?.ControlType)) { var lookupTable = GetProp(PropLookupTable, ""); if (!string.IsNullOrEmpty(lookupTable)) @@ -460,9 +1109,37 @@ if (_selected?.ControlType != "childtabs") _currentTabs = []; - if (_selected?.ControlType != "lookup") + if (_selected?.ControlType is not ("lookup" or "comboBox" or "listBox" or "optionGroup")) _lookupTableDef = null; + if (_selected?.ControlType != "commandButton") + { + _loadedCommandArgumentControlId = null; + _commandArgumentText = string.Empty; + _commandArgumentError = null; + } + + if (_selected?.ControlType is not ("select" or "comboBox" or "listBox" or "optionGroup")) + { + _loadedOptionsControlId = null; + _optionsText = string.Empty; + _optionsError = null; + } + + if (_selected?.ControlType != "tabControl") + { + _loadedTabPagesControlId = null; + _tabPagesText = string.Empty; + _tabPagesError = null; + } + + if (_selected?.ControlType != "computed") + { + _showFormulaHelper = false; + _formulaFunctionQuery = string.Empty; + _formulaFunctionCategory = string.Empty; + } + await InvokeAsync(StateHasChanged); } @@ -475,21 +1152,288 @@ private bool GetBoolProp(string key) { - if (_selected?.Props.Values.TryGetValue(key, out var val) == true && val is bool b) - return b; + if (_selected?.Props.Values.TryGetValue(key, out var val) == true) + return ReadBool(val, false); return false; } + private FormControlDescriptor? GetSelectedDescriptor() + => _selected is not null && ControlRegistry.TryGetControl(_selected.ControlType, out FormControlDescriptor descriptor) + ? descriptor + : null; + + private IReadOnlyList GetTypeDropdownControls() + => ControlRegistry.Controls; + + private bool IsRegisteredControlType(string controlType) + => ControlRegistry.TryGetControl(controlType, out _); + + private bool IsTabOrderControl(ControlDefinition control) + => ControlRegistry.TryGetControl(control.ControlType, out FormControlDescriptor descriptor) + ? descriptor.ParticipatesInTabOrder + : control.ControlType is not ("label" or "datagrid" or "childtabs" or "tabControl" or "subform"); + + private string GetRegisteredPropertyValue(FormControlPropertyDescriptor property) + { + if (_selected?.Props.Values.TryGetValue(property.Name, out object? value) == true && value is not null) + return value.ToString() ?? string.Empty; + + return property.DefaultValue?.ToString() ?? string.Empty; + } + + private bool GetRegisteredBoolPropertyValue(FormControlPropertyDescriptor property) + { + if (_selected?.Props.Values.TryGetValue(property.Name, out object? value) == true) + return ReadBool(value, ReadBool(property.DefaultValue, false)); + + return ReadBool(property.DefaultValue, false); + } + + private void OnRegisteredPropertyChanged(FormControlPropertyDescriptor property, object? rawValue) + { + object? value = property.Editor switch + { + FormControlPropertyEditor.Checkbox => rawValue is bool b && b, + FormControlPropertyEditor.Number => ParseScalar(rawValue), + _ => rawValue?.ToString(), + }; + + OnPropChanged(property.Name, value); + } + + private Dictionary GetPropertyEditorParameters(FormControlDescriptor descriptor) + => new() + { + ["Context"] = new FormControlPropertyContext( + _selected!, + descriptor, + _sourceTableDef, + _availableTables, + _availableForms, + (key, value) => + { + OnPropChanged(key, value); + return Task.CompletedTask; + }), + }; + + private bool IsAttachmentTableMode() + => string.Equals(GetProp(PropStorageMode, "blobField"), "attachmentTable", StringComparison.OrdinalIgnoreCase); + + private bool GetAnchorProp(string key, bool fallback) + { + if (_selected?.Props.Values.TryGetValue(key, out var val) == true) + return ReadBool(val, fallback); + return fallback; + } + private void OnPropChanged(string key, object? value) { if (_selected is null) return; State.UpdateControlProp(_selected.ControlId, key, value); } + private void ToggleFormulaHelper() + { + _showFormulaHelper = !_showFormulaHelper; + } + + private void OnFormulaCategoryChanged(ChangeEventArgs e) + { + _formulaFunctionCategory = e.Value?.ToString() ?? string.Empty; + } + + private void OnFormulaQueryChanged(ChangeEventArgs e) + { + _formulaFunctionQuery = e.Value?.ToString() ?? string.Empty; + } + + private IReadOnlyList GetFormulaFunctionGroups() + { + IEnumerable functions = FormulaFunctionCatalog.AllFunctions; + if (!string.IsNullOrWhiteSpace(_formulaFunctionCategory)) + { + functions = functions.Where(function => + string.Equals(function.Category, _formulaFunctionCategory, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(_formulaFunctionQuery)) + { + string query = _formulaFunctionQuery.Trim(); + functions = functions.Where(function => FormulaFunctionMatches(function, query)); + } + + return functions + .GroupBy(static function => function.Category, StringComparer.OrdinalIgnoreCase) + .Select(static group => new FormulaFunctionGroup(group.Key, group.ToArray())) + .ToArray(); + } + + private static bool FormulaFunctionMatches(FormulaFunctionDescriptor function, string query) + => function.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Signature.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Description.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Example.Contains(query, StringComparison.OrdinalIgnoreCase); + + private void InsertFormulaFunction(FormulaFunctionDescriptor function) + { + AppendFormulaText(function.InsertText); + } + + private void UseFormulaExample(FormulaFunctionDescriptor function) + { + OnPropChanged(PropFormula, function.Example); + } + + private void InsertFormulaField(string fieldName) + { + if (string.IsNullOrWhiteSpace(fieldName)) + return; + + AppendFormulaText($"[{fieldName}]"); + } + + private void AppendFormulaText(string text) + { + if (_selected is null || string.IsNullOrWhiteSpace(text)) + return; + + string current = GetProp(PropFormula, string.Empty).TrimEnd(); + string updated = current switch + { + "" => $"={text}", + "=" => $"={text}", + _ when current.StartsWith('=') => $"{current} {text}", + _ => $"={current} {text}", + }; + + OnPropChanged(PropFormula, updated); + } + + private string GetAnchorPreset() + { + var anchors = GetCurrentAnchors(); + + return anchors switch + { + (true, true, false, false) => "topLeft", + (false, true, true, false) => "topRight", + (true, false, false, true) => "bottomLeft", + (false, false, true, true) => "bottomRight", + (true, true, true, false) => "stretchAcross", + (true, true, false, true) => "stretchDown", + (true, true, true, true) => "stretchBoth", + _ => "custom", + }; + } + + private void OnAnchorPresetChanged(ChangeEventArgs e) + { + if (_selected is null) return; + + string preset = e.Value?.ToString() ?? string.Empty; + if (preset == "custom") + return; + + var anchors = preset switch + { + "topLeft" => (Left: true, Top: true, Right: false, Bottom: false), + "topRight" => (Left: false, Top: true, Right: true, Bottom: false), + "bottomLeft" => (Left: true, Top: false, Right: false, Bottom: true), + "bottomRight" => (Left: false, Top: false, Right: true, Bottom: true), + "stretchAcross" => (Left: true, Top: true, Right: true, Bottom: false), + "stretchDown" => (Left: true, Top: true, Right: false, Bottom: true), + "stretchBoth" => (Left: true, Top: true, Right: true, Bottom: true), + _ => GetCurrentAnchors(), + }; + + UpdateAnchorProps(anchors); + } + + private void OnAnchorChanged(string key, object? value) + { + if (_selected is null) return; + + var anchors = GetCurrentAnchors(); + bool left = anchors.Left; + bool top = anchors.Top; + bool right = anchors.Right; + bool bottom = anchors.Bottom; + bool isChecked = value is bool b && b; + + switch (key) + { + case PropAnchorLeft: + left = isChecked; + break; + case PropAnchorTop: + top = isChecked; + break; + case PropAnchorRight: + right = isChecked; + break; + case PropAnchorBottom: + bottom = isChecked; + break; + } + + if (!left && !right) + { + if (key == PropAnchorLeft) + right = true; + else + left = true; + } + + if (!top && !bottom) + { + if (key == PropAnchorTop) + bottom = true; + else + top = true; + } + + UpdateAnchorProps((left, top, right, bottom)); + } + + private (bool Left, bool Top, bool Right, bool Bottom) GetCurrentAnchors() + => ( + GetAnchorProp(PropAnchorLeft, true), + GetAnchorProp(PropAnchorTop, true), + GetAnchorProp(PropAnchorRight, false), + GetAnchorProp(PropAnchorBottom, false)); + + private void UpdateAnchorProps((bool Left, bool Top, bool Right, bool Bottom) anchors) + { + if (_selected is null) return; + + State.UpdateControlProps( + _selected.ControlId, + new Dictionary + { + [PropAnchorLeft] = anchors.Left, + [PropAnchorTop] = anchors.Top, + [PropAnchorRight] = anchors.Right, + [PropAnchorBottom] = anchors.Bottom, + }); + } + + private void OnFormNameChanged(ChangeEventArgs e) + { + State.SetFormName(e.Value?.ToString()); + } + private void OnTypeChanged(ChangeEventArgs e) { if (_selected is null || e.Value is not string newType) return; State.UpdateControlType(_selected.ControlId, newType); + if (ControlRegistry.TryGetControl(newType, out FormControlDescriptor descriptor)) + { + if (!descriptor.SupportsBinding && _selected.Binding is not null) + State.UpdateControlBinding(_selected.ControlId, null); + else if (descriptor.SupportsBinding && _selected.Binding is null) + State.UpdateControlBinding(_selected.ControlId, new BindingDefinition("", "TwoWay")); + } } private void OnRectChanged(ChangeEventArgs e, RectPart part) @@ -540,8 +1484,61 @@ return long.TryParse(s, out var l) ? l : null; } + private static object? ParseNonNegativeDouble(object? value) + { + if (value is null) return null; + var s = value.ToString(); + if (string.IsNullOrWhiteSpace(s)) return null; + return double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? Math.Max(0, parsed) + : null; + } + + private static bool ReadBool(object? value, bool fallback) + { + return value switch + { + bool b => b, + JsonElement json when json.ValueKind == JsonValueKind.True => true, + JsonElement json when json.ValueKind == JsonValueKind.False => false, + JsonElement json when json.ValueKind == JsonValueKind.String && bool.TryParse(json.GetString(), out bool parsed) => parsed, + _ when bool.TryParse(value?.ToString(), out bool parsed) => parsed, + _ => fallback, + }; + } + // ===== DataGrid Configuration Methods ===== + private async Task OnDataGridModeChanged(ChangeEventArgs e) + { + if (_selected is null || e.Value is not string mode) + return; + + mode = string.Equals(mode, "standalone", StringComparison.OrdinalIgnoreCase) + ? "standalone" + : "related"; + OnPropChanged(PropDataGridMode, mode); + + if (string.Equals(mode, "standalone", StringComparison.OrdinalIgnoreCase)) + { + OnPropChanged(PropForeignKeyField, ""); + OnPropChanged(PropParentKeyField, ""); + OnPropChanged(PropForeignKeyName, ""); + return; + } + + if (_childTableDef is not null) + { + var matchingFks = _childTableDef.ForeignKeys + .Where(fk => string.Equals(fk.ReferencedTable, State.TableName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matchingFks.Count == 1) + AutoSelectForeignKey(matchingFks[0]); + } + + await Task.CompletedTask; + } + private async Task OnChildTableChanged(ChangeEventArgs e) { if (_selected is null || e.Value is not string tableName) return; @@ -558,18 +1555,21 @@ if (_childTableDef is not null) { - // Auto-select FK if exactly one references the parent table - var matchingFks = _childTableDef.ForeignKeys - .Where(fk => string.Equals(fk.ReferencedTable, State.TableName, StringComparison.OrdinalIgnoreCase)) - .ToList(); - if (matchingFks.Count == 1) + if (!IsStandaloneDataGrid()) { - AutoSelectForeignKey(matchingFks[0]); + // Auto-select FK if exactly one references the parent table + var matchingFks = _childTableDef.ForeignKeys + .Where(fk => string.Equals(fk.ReferencedTable, State.TableName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matchingFks.Count == 1) + { + AutoSelectForeignKey(matchingFks[0]); + } } - // Auto-select all non-PK columns as visible + // Related grids keep PKs hidden by default; standalone grids show the mapped table as-is. var allCols = _childTableDef.Fields - .Where(f => !_childTableDef.PrimaryKey.Contains(f.Name)) + .Where(f => IsStandaloneDataGrid() || !_childTableDef.PrimaryKey.Contains(f.Name)) .Select(f => (object?)f.Name) .ToArray(); OnPropChanged(PropVisibleColumns, allCols); @@ -674,6 +1674,247 @@ } } + private string GetOptionsText() + { + if (_selected is null) + return string.Empty; + + if (string.Equals(_loadedOptionsControlId, _selected.ControlId, StringComparison.Ordinal)) + return _optionsText; + + _loadedOptionsControlId = _selected.ControlId; + _optionsError = null; + IReadOnlyList options = _selected.Props.Values.TryGetValue(PropOptions, out object? value) + ? FormChoiceResolver.ReadOptions(value) + : []; + _optionsText = string.Join(Environment.NewLine, options.Select(option => $"{option.Value}|{option.Label}")); + return _optionsText; + } + + private void OnOptionsChanged(string text) + { + _optionsText = text; + _optionsError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + OnPropChanged(PropOptions, Array.Empty()); + return; + } + + var options = new List(); + foreach (string line in text.Split(["\r\n", "\n"], StringSplitOptions.None)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + string[] parts = line.Split('|', 2); + string value = parts[0].Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + _optionsError = "Each option needs a value before the pipe."; + return; + } + + string label = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) + ? parts[1].Trim() + : value; + options.Add(new Dictionary + { + ["value"] = value, + ["label"] = label, + }); + } + + OnPropChanged(PropOptions, options.ToArray()); + } + + private string GetDisplayFieldsText() + { + if (_selected?.Props.Values.TryGetValue(PropDisplayFields, out object? value) == true) + return string.Join(", ", FormChoiceResolver.ReadStringList(value)); + + return string.Empty; + } + + private void OnDisplayFieldsChanged(string text) + { + object?[] fields = text + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(field => (object?)field) + .ToArray(); + OnPropChanged(PropDisplayFields, fields); + } + + private static object? ParseScalar(object? value) + { + string text = value?.ToString()?.Trim() ?? string.Empty; + if (text.Length == 0) + return null; + + if (bool.TryParse(text, out bool boolValue)) + return boolValue; + if (long.TryParse(text, out long longValue)) + return longValue; + if (double.TryParse(text, out double doubleValue)) + return doubleValue; + + return text; + } + + private string GetTabPagesText() + { + if (_selected is null) + return string.Empty; + + if (string.Equals(_loadedTabPagesControlId, _selected.ControlId, StringComparison.Ordinal)) + return _tabPagesText; + + _loadedTabPagesControlId = _selected.ControlId; + _tabPagesError = null; + _tabPagesText = string.Join(Environment.NewLine, ReadTabPages(_selected).Select(tab => $"{tab.Id}|{tab.Label}")); + return _tabPagesText; + } + + private void OnTabPagesChanged(string text) + { + _tabPagesText = text; + _tabPagesError = null; + + var tabs = new List(); + foreach (string line in text.Split(["\r\n", "\n"], StringSplitOptions.None)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + string[] parts = line.Split('|', 2); + string id = parts[0].Trim(); + if (string.IsNullOrWhiteSpace(id)) + { + _tabPagesError = "Each tab page needs an id before the pipe."; + return; + } + + string label = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) + ? parts[1].Trim() + : id; + tabs.Add(new Dictionary + { + ["id"] = id, + ["label"] = label, + }); + } + + OnPropChanged(PropTabs, tabs.ToArray()); + } + + private IReadOnlyList<(string Id, string Label)> ReadTabPages(ControlDefinition control) + { + if (!control.Props.Values.TryGetValue(PropTabs, out object? value) || value is null) + return []; + + if (value is JsonElement json && json.ValueKind == JsonValueKind.Array) + { + return json.EnumerateArray() + .Select(element => ReadTabPage(element)) + .Where(tab => tab is not null) + .Select(tab => tab!.Value) + .ToArray(); + } + + if (value is IEnumerable items) + { + return items + .Select(item => ReadTabPage(item)) + .Where(tab => tab is not null) + .Select(tab => tab!.Value) + .ToArray(); + } + + return []; + } + + private static (string Id, string Label)? ReadTabPage(object? value) + { + if (value is JsonElement json) + { + if (json.ValueKind != JsonValueKind.Object) + return null; + + string id = json.TryGetProperty("id", out JsonElement idValue) ? idValue.ToString() : string.Empty; + string label = json.TryGetProperty("label", out JsonElement labelValue) ? labelValue.ToString() : id; + return string.IsNullOrWhiteSpace(id) ? null : (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + if (value is IReadOnlyDictionary readOnly) + return ReadTabPage(readOnly); + + if (value is IDictionary dictionary) + return ReadTabPage(dictionary); + + return null; + } + + private static (string Id, string Label)? ReadTabPage(IReadOnlyDictionary dictionary) + { + string id = dictionary.TryGetValue("id", out object? idValue) ? idValue?.ToString() ?? string.Empty : string.Empty; + if (string.IsNullOrWhiteSpace(id)) + return null; + + string label = dictionary.TryGetValue("label", out object? labelValue) ? labelValue?.ToString() ?? id : id; + return (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + private List GetTabControls() + => State.Controls + .Where(control => control.ControlType == "tabControl" && !string.Equals(control.ControlId, _selected?.ControlId, StringComparison.Ordinal)) + .ToList(); + + private ControlDefinition? GetSelectedParentTabControl() + { + string parentControlId = GetProp(PropParentControlId, string.Empty); + return string.IsNullOrWhiteSpace(parentControlId) + ? null + : State.Controls.FirstOrDefault(control => string.Equals(control.ControlId, parentControlId, StringComparison.Ordinal)); + } + + private void OnParentTabControlChanged(ChangeEventArgs e) + { + if (_selected is null) + return; + + string parentControlId = e.Value?.ToString() ?? string.Empty; + OnPropChanged(PropParentControlId, parentControlId); + if (string.IsNullOrWhiteSpace(parentControlId)) + { + OnPropChanged(PropParentTabId, string.Empty); + return; + } + + ControlDefinition? parent = State.Controls.FirstOrDefault(control => string.Equals(control.ControlId, parentControlId, StringComparison.Ordinal)); + string firstTabId = parent is null ? string.Empty : ReadTabPages(parent).FirstOrDefault().Id ?? string.Empty; + OnPropChanged(PropParentTabId, firstTabId); + } + + private static string GetControlDisplayName(ControlDefinition control) + => control.Props.Values.TryGetValue(PropText, out object? text) && !string.IsNullOrWhiteSpace(text?.ToString()) + ? $"{text} ({control.ControlId[..Math.Min(8, control.ControlId.Length)]})" + : $"{control.ControlType} ({control.ControlId[..Math.Min(8, control.ControlId.Length)]})"; + + private RenderFragment RenderFieldSelect(string propName, FormTableDefinition? table, string emptyText) => @; + + private static bool IsLookupChoiceControl(string? controlType) + => controlType is "lookup" or "comboBox" or "listBox" or "optionGroup"; + // ===== ChildTabs Configuration Methods ===== private void OnTabsChanged(List tabs) @@ -682,6 +1923,105 @@ OnPropChanged("tabs", ChildTabConfigMapper.ToPropertyBag(tabs)); } + private string GetCommandArgumentsText() + { + if (_selected is null) + return string.Empty; + + if (string.Equals(_loadedCommandArgumentControlId, _selected.ControlId, StringComparison.Ordinal)) + return _commandArgumentText; + + _loadedCommandArgumentControlId = _selected.ControlId; + _commandArgumentError = null; + _commandArgumentText = _selected.Props.Values.TryGetValue(PropCommandArguments, out object? value) + ? FormatArguments(FormCommandInvocation.ReadArgumentsProperty(value)) + : string.Empty; + return _commandArgumentText; + } + + private void OnCommandArgumentsChanged(string text) + { + _commandArgumentText = text; + _commandArgumentError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + OnPropChanged(PropCommandArguments, null); + return; + } + + try + { + IReadOnlyDictionary? arguments = + JsonSerializer.Deserialize>(text, JsonDefaults.Options); + OnPropChanged(PropCommandArguments, arguments); + } + catch (JsonException ex) + { + _commandArgumentError = $"Invalid arguments JSON: {ex.Message}"; + } + } + + private bool IsStandaloneDataGrid() + => string.Equals(GetProp(PropDataGridMode, "related"), "standalone", StringComparison.OrdinalIgnoreCase); + + private bool ShouldRenderMissingCommand(string commandName) + => !string.IsNullOrWhiteSpace(commandName) + && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + private static string FormatArguments(IReadOnlyDictionary? arguments) + => arguments is null || arguments.Count == 0 + ? string.Empty + : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); + + private Task OnEventBindingsChanged(IReadOnlyList bindings) + { + State.UpdateEventBindings(bindings); + return Task.CompletedTask; + } + + private Task OnActionSequencesChanged(IReadOnlyList sequences) + { + State.UpdateActionSequences(sequences); + return Task.CompletedTask; + } + + private Task OnRulesChanged(IReadOnlyList rules) + { + State.UpdateRules(rules); + return Task.CompletedTask; + } + + private Task OnFormValidationRulesChanged(IReadOnlyList rules) + { + State.UpdateValidationRules(rules); + return Task.CompletedTask; + } + + private Task OnControlValidationRulesChanged(IReadOnlyList rules) + { + if (_selected is null) + return Task.CompletedTask; + + ValidationOverride? existing = _selected.ValidationOverride; + bool disableInferredRules = existing?.DisableInferredRules ?? false; + IReadOnlyList disableRuleIds = existing?.DisableRuleIds ?? []; + ValidationOverride? updated = rules.Count == 0 && !disableInferredRules && disableRuleIds.Count == 0 + ? null + : new ValidationOverride(disableInferredRules, rules.ToList(), disableRuleIds); + + State.UpdateControlValidationOverride(_selected.ControlId, updated); + return Task.CompletedTask; + } + + private Task OnControlEventBindingsChanged(IReadOnlyList bindings) + { + if (_selected is not null) + State.UpdateControlEventBindings(_selected.ControlId, bindings); + + return Task.CompletedTask; + } + private async Task LoadTableDefinitionAsync(string? tableName) { if (string.IsNullOrWhiteSpace(tableName)) diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor b/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor index 839aabd2..0a85f943 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor @@ -1,4 +1,6 @@ @using CSharpDB.Admin.Forms.Components.Designer +@using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Models @implements IDisposable
@@ -13,77 +15,21 @@
-
-
Layout
- -
- -
-
Input Controls
- - - - - - - - - -
- -
-
Data
- - -
+ @foreach (var group in GetToolboxGroups()) + { +
+
@group.Name
+ @foreach (FormControlDescriptor control in group.Controls) + { + + } +
+ }
Clipboard
@@ -146,24 +92,20 @@ @code { [CascadingParameter] public DesignerState State { get; set; } = default!; - - private const string ToolLabel = "label"; - private const string ToolText = "text"; - private const string ToolTextarea = "textarea"; - private const string ToolNumber = "number"; - private const string ToolDate = "date"; - private const string ToolCheckbox = "checkbox"; - private const string ToolRadio = "radio"; - private const string ToolSelect = "select"; - private const string ToolDatagrid = "datagrid"; - private const string ToolChildTabs = "childtabs"; - private const string ToolLookup = "lookup"; - private const string ToolComputed = "computed"; + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; protected override void OnInitialized() => State.OnChange += OnStateChanged; public void Dispose() => State.OnChange -= OnStateChanged; private void OnStateChanged() => InvokeAsync(StateHasChanged); + private IReadOnlyList<(string Name, IReadOnlyList Controls)> GetToolboxGroups() + => ControlRegistry.GetToolboxControls() + .GroupBy(control => control.ToolboxGroup) + .Select(group => ( + Name: group.Key, + Controls: (IReadOnlyList)group.ToArray())) + .ToArray(); + private string ToolClass(string? tool) => "toolbox-item" + (State.ActiveTool == tool ? " active" : ""); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ValidationRulesEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ValidationRulesEditor.razor new file mode 100644 index 00000000..534d5883 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ValidationRulesEditor.razor @@ -0,0 +1,198 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives +@inject DbValidationRuleRegistry ValidationRuleRegistry + +
+ @if (Rules.Count == 0) + { +
No validation rules
+ } + + @for (int i = 0; i < Rules.Count; i++) + { + var ruleIndex = i; + var rule = Rules[ruleIndex]; +
+
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (_parameterErrors.TryGetValue(ruleIndex, out string? error)) + { +
@error
+ } +
+
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList Rules { get; set; } = []; + [Parameter] public EventCallback> RulesChanged { get; set; } + + private readonly Dictionary _parameterErrors = []; + + private IReadOnlyList RegisteredRules + => ValidationRuleRegistry.Rules + .OrderBy(static rule => rule.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private async Task AddRule() + { + var updated = Rules + .Append(new ValidationRule(NextRuleName(), string.Empty, new Dictionary(StringComparer.OrdinalIgnoreCase))) + .ToList(); + await RulesChanged.InvokeAsync(updated); + } + + private async Task RemoveRule(int index) + { + var updated = Rules.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated.RemoveAt(index); + _parameterErrors.Remove(index); + await RulesChanged.InvokeAsync(updated); + } + + private Task UpdateRuleFromDropdown(int index, ValidationRule rule, string value) + => string.IsNullOrWhiteSpace(value) + ? Task.CompletedTask + : ReplaceRule(index, rule with { RuleId = value.Trim() }); + + private Task UpdateRuleId(int index, ValidationRule rule, string value) + => ReplaceRule(index, rule with { RuleId = value.Trim() }); + + private Task UpdateMessage(int index, ValidationRule rule, string value) + => ReplaceRule(index, rule with { Message = value.Trim() }); + + private async Task UpdateParameters(int index, ValidationRule rule, string text) + { + try + { + IReadOnlyDictionary parameters = ParseParameters(text); + _parameterErrors.Remove(index); + await ReplaceRule(index, rule with { Parameters = parameters }); + } + catch (JsonException ex) + { + _parameterErrors[index] = $"Invalid JSON: {ex.Message}"; + } + catch (InvalidOperationException ex) + { + _parameterErrors[index] = ex.Message; + } + } + + private async Task ReplaceRule(int index, ValidationRule rule) + { + var updated = Rules.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated[index] = rule; + await RulesChanged.InvokeAsync(updated); + } + + private string GetRuleSelectValue(ValidationRule rule) + => RegisteredRules.Any(definition => string.Equals(definition.Name, rule.RuleId, StringComparison.OrdinalIgnoreCase)) + ? rule.RuleId + : string.Empty; + + private string NextRuleName() + { + HashSet existing = Rules + .Select(static rule => rule.RuleId) + .Where(static ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (DbValidationRuleDefinition definition in RegisteredRules) + { + if (!existing.Contains(definition.Name)) + return definition.Name; + } + + for (int i = 1; ; i++) + { + string candidate = $"Rule{i}"; + if (!existing.Contains(candidate)) + return candidate; + } + } + + private static string FormatParameters(IReadOnlyDictionary parameters) + => parameters.Count == 0 + ? "{ }" + : JsonSerializer.Serialize(parameters, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); + + private static IReadOnlyDictionary ParseParameters(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + using JsonDocument document = JsonDocument.Parse(text); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("Parameters must be a JSON object."); + + return document.RootElement + .EnumerateObject() + .ToDictionary( + static property => property.Name, + static property => ReadJsonValue(property.Value), + StringComparer.OrdinalIgnoreCase); + } + + private static object? ReadJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary( + static property => property.Name, + static property => ReadJsonValue(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => value.EnumerateArray().Select(ReadJsonValue).ToArray(), + _ => value.ToString(), + }; +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/ControlFilterState.cs b/src/CSharpDB.Admin.Forms/Contracts/ControlFilterState.cs new file mode 100644 index 00000000..68be9154 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/ControlFilterState.cs @@ -0,0 +1,5 @@ +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record ControlFilterState( + string FilterExpression, + IReadOnlyDictionary Parameters); diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionDiagnostics.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionDiagnostics.cs new file mode 100644 index 00000000..a504eef9 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionDiagnostics.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormActionInvocationDiagnostic( + DbActionKind ActionKind, + string? Target, + string? FormId, + string? FormName, + string? TableName, + string? EventName, + string? ActionSequenceName, + int StepIndex, + string? Location, + TimeSpan Elapsed, + bool Succeeded, + bool Canceled, + string? ResultMessage, + string? ExceptionMessage, + IReadOnlyDictionary Metadata); + +public static class FormActionDiagnostics +{ + public const string ListenerName = "CSharpDB.Admin.Forms.Actions"; + public const string InvocationEventName = "CSharpDB.Admin.Forms.Actions.Invocation"; + + public static DiagnosticListener Listener { get; } = new(ListenerName); + + public static bool IsInvocationEnabled + => Listener.IsEnabled(InvocationEventName); + + internal static long GetTimestamp() + => Stopwatch.GetTimestamp(); + + internal static TimeSpan GetElapsedTime(long startingTimestamp) + => Stopwatch.GetElapsedTime(startingTimestamp); + + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Form action diagnostics are emitted only for subscribed hosts; the strongly typed event payload is part of the public diagnostics contract.")] + internal static void WriteInvocation( + DbActionKind actionKind, + string? target, + IReadOnlyDictionary? metadata, + TimeSpan elapsed, + bool succeeded, + bool canceled, + string? resultMessage, + string? exceptionMessage) + { + if (!IsInvocationEnabled) + return; + + IReadOnlyDictionary metadataSnapshot = CopyMetadata(metadata); + Listener.Write( + InvocationEventName, + new FormActionInvocationDiagnostic( + actionKind, + string.IsNullOrWhiteSpace(target) ? null : target, + ReadMetadata(metadataSnapshot, "formId"), + ReadMetadata(metadataSnapshot, "formName"), + ReadMetadata(metadataSnapshot, "tableName"), + ReadMetadata(metadataSnapshot, "event"), + ReadMetadata(metadataSnapshot, "actionSequence"), + ReadStepIndex(metadataSnapshot), + BuildLocation(metadataSnapshot), + elapsed, + succeeded, + canceled, + resultMessage, + exceptionMessage, + metadataSnapshot)); + } + + private static IReadOnlyDictionary CopyMetadata( + IReadOnlyDictionary? metadata) + { + if (metadata is null || metadata.Count == 0) + return EmptyStringDictionary.Instance; + + return new Dictionary(metadata, StringComparer.OrdinalIgnoreCase); + } + + private static string? BuildLocation(IReadOnlyDictionary metadata) + { + if (TryReadMetadata(metadata, "location", out string? location)) + return location; + + string? eventName = ReadMetadata(metadata, "event"); + string? actionSequence = ReadMetadata(metadata, "actionSequence"); + string? actionStep = ReadMetadata(metadata, "actionStep"); + if (!string.IsNullOrWhiteSpace(actionSequence) && !string.IsNullOrWhiteSpace(actionStep)) + return $"actionSequences.{actionSequence}.steps[{actionStep}]"; + + string? controlId = ReadMetadata(metadata, "controlId"); + if (!string.IsNullOrWhiteSpace(controlId) && !string.IsNullOrWhiteSpace(eventName)) + return $"controls.{controlId}.events.{eventName}"; + + if (!string.IsNullOrWhiteSpace(eventName) && !string.IsNullOrWhiteSpace(actionStep)) + return $"events.{eventName}.actionSequence.steps[{actionStep}]"; + + if (!string.IsNullOrWhiteSpace(actionStep)) + return $"action.steps[{actionStep}]"; + + return null; + } + + private static int ReadStepIndex(IReadOnlyDictionary metadata) + => int.TryParse(ReadMetadata(metadata, "actionStep"), out int stepIndex) + ? stepIndex + : -1; + + private static string? ReadMetadata(IReadOnlyDictionary metadata, string key) + => TryReadMetadata(metadata, key, out string? value) ? value : null; + + private static bool TryReadMetadata( + IReadOnlyDictionary metadata, + string key, + out string? value) + { + if (metadata.TryGetValue(key, out string? raw) && !string.IsNullOrWhiteSpace(raw)) + { + value = raw; + return true; + } + + value = null; + return false; + } + + private static class EmptyStringDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs new file mode 100644 index 00000000..8fbc17e9 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs @@ -0,0 +1,13 @@ +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormOpenRequest( + string FormId, + string FormName, + IReadOnlyDictionary Arguments, + string? Mode = null, + object? RecordId = null, + string? FilterExpression = null); + +public sealed record FormCloseRequest( + string? FormId, + string? FormName); diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionRuntimeContext.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionRuntimeContext.cs new file mode 100644 index 00000000..7896ccd0 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionRuntimeContext.cs @@ -0,0 +1,14 @@ +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormActionRuntimeContext( + string? FormId, + string? FormName, + string? TableName, + string? EventName, + string? ActionSequenceName, + int StepIndex, + IReadOnlyDictionary? Record, + IReadOnlyDictionary? BindingArguments, + IReadOnlyDictionary? RuntimeArguments, + IReadOnlyDictionary? StepArguments, + IReadOnlyDictionary Metadata); diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionValidationModels.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionValidationModels.cs new file mode 100644 index 00000000..420a33f7 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionValidationModels.cs @@ -0,0 +1,52 @@ +using CSharpDB.Primitives; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormActionValidationResult( + bool Succeeded, + IReadOnlyList Issues); + +public sealed record FormActionValidationIssue( + FormActionValidationSeverity Severity, + DbActionKind ActionKind, + string Surface, + string Location, + string Message, + string? Target = null, + string? EventName = null, + string? ActionSequence = null, + int? StepIndex = null); + +public enum FormActionValidationSeverity +{ + Warning, + Error, +} + +public sealed record FormActionRuntimeCapabilities( + bool RecordActions = false, + bool OpenForm = false, + bool CloseForm = false, + bool ApplyFilter = false, + bool ClearFilter = false, + bool RunSql = false, + bool RunProcedure = false, + bool SetControlProperty = false) +{ + public static FormActionRuntimeCapabilities None { get; } = new(); + + public static FormActionRuntimeCapabilities RenderedForm { get; } = new( + RecordActions: true, + OpenForm: true, + CloseForm: true, + ApplyFilter: true, + ClearFilter: true, + SetControlProperty: true); +} + +public sealed record FormActionValidationOptions( + FormActionRuntimeCapabilities? RuntimeCapabilities = null, + IReadOnlyCollection? AvailableForms = null, + IReadOnlyCollection? AvailableProcedures = null, + FormTableDefinition? Schema = null); diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormEventDispatchResult.cs b/src/CSharpDB.Admin.Forms/Contracts/FormEventDispatchResult.cs new file mode 100644 index 00000000..2279994d --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormEventDispatchResult.cs @@ -0,0 +1,12 @@ +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormEventDispatchResult(bool Succeeded, string? Message = null) +{ + public static FormEventDispatchResult Success(string? message = null) => new(true, message); + + public static FormEventDispatchResult Failure(string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return new(false, message); + } +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/IFormActionRuntime.cs b/src/CSharpDB.Admin.Forms/Contracts/IFormActionRuntime.cs new file mode 100644 index 00000000..0193e082 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/IFormActionRuntime.cs @@ -0,0 +1,53 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Contracts; + +public interface IFormActionRuntime +{ + Task ExecuteRecordActionAsync( + FormActionRuntimeContext context, + DbActionStep step, + CancellationToken ct); + + Task OpenFormAsync( + FormActionRuntimeContext context, + string formName, + IReadOnlyDictionary arguments, + CancellationToken ct); + + Task CloseFormAsync( + FormActionRuntimeContext context, + string? formName, + CancellationToken ct); + + Task ApplyFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct); + + Task ClearFilterAsync( + FormActionRuntimeContext context, + string target, + CancellationToken ct); + + Task RunSqlAsync( + FormActionRuntimeContext context, + string sqlOrName, + IReadOnlyDictionary arguments, + CancellationToken ct); + + Task RunProcedureAsync( + FormActionRuntimeContext context, + string procedureName, + IReadOnlyDictionary arguments, + CancellationToken ct); + + Task SetControlPropertyAsync( + FormActionRuntimeContext context, + string controlId, + string propertyName, + object? value, + CancellationToken ct); +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/IFormControlRegistry.cs b/src/CSharpDB.Admin.Forms/Contracts/IFormControlRegistry.cs new file mode 100644 index 00000000..f91bd82e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/IFormControlRegistry.cs @@ -0,0 +1,12 @@ +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Contracts; + +public interface IFormControlRegistry +{ + IReadOnlyList Controls { get; } + + bool TryGetControl(string controlType, out FormControlDescriptor descriptor); + + IReadOnlyList GetToolboxControls(); +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/IFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Contracts/IFormEventDispatcher.cs new file mode 100644 index 00000000..7a03829a --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/IFormEventDispatcher.cs @@ -0,0 +1,12 @@ +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Contracts; + +public interface IFormEventDispatcher +{ + Task DispatchAsync( + FormDefinition form, + FormEventKind eventKind, + IReadOnlyDictionary? record = null, + CancellationToken ct = default); +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/IFormRecordService.cs b/src/CSharpDB.Admin.Forms/Contracts/IFormRecordService.cs index c78ce407..9c9801f1 100644 --- a/src/CSharpDB.Admin.Forms/Contracts/IFormRecordService.cs +++ b/src/CSharpDB.Admin.Forms/Contracts/IFormRecordService.cs @@ -16,5 +16,6 @@ public interface IFormRecordService Task>> ListFilteredRecordsAsync(FormTableDefinition table, string filterField, object? filterValue, CancellationToken ct = default); Task> CreateRecordAsync(FormTableDefinition table, Dictionary values, CancellationToken ct = default); Task> UpdateRecordAsync(FormTableDefinition table, object pkValue, Dictionary values, CancellationToken ct = default); + Task SaveAttachmentAsync(FormAttachmentTableBinding binding, object parentValue, FormAttachmentValue attachment, CancellationToken ct = default); Task DeleteRecordAsync(FormTableDefinition table, object pkValue, CancellationToken ct = default); } diff --git a/src/CSharpDB.Admin.Forms/Contracts/IValidationInferenceService.cs b/src/CSharpDB.Admin.Forms/Contracts/IValidationInferenceService.cs index e40b91c1..335bf04b 100644 --- a/src/CSharpDB.Admin.Forms/Contracts/IValidationInferenceService.cs +++ b/src/CSharpDB.Admin.Forms/Contracts/IValidationInferenceService.cs @@ -6,4 +6,8 @@ public interface IValidationInferenceService { IReadOnlyList InferRules(FormFieldDefinition field); IReadOnlyList Evaluate(FormDefinition form, IDictionary record); + Task> EvaluateAsync( + FormDefinition form, + IDictionary record, + CancellationToken ct = default); } diff --git a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs index 46df04c1..bb94c79e 100644 --- a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs @@ -1,39 +1,100 @@ +using System.Globalization; +using System.Text.Json; +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Forms.Evaluation; +public sealed record FormulaDomainFunctionRequest( + string FunctionName, + string Expression, + string Domain, + string? Criteria); + +public delegate object? FormulaDomainFunctionResolver(FormulaDomainFunctionRequest request); + /// -/// Evaluates formulas for computed fields. -/// -/// Field formulas start with '=' and support arithmetic (+, -, *, /), parentheses, -/// numeric literals, and field references (e.g., =Quantity * UnitPrice). +/// Evaluates Admin Forms formulas. /// -/// Aggregate formulas reference child table fields: =SUM(OrderItems.LineTotal), -/// =COUNT(OrderItems.LineTotal), =AVG(...), =MIN(...), =MAX(...) +/// Formulas start with '=' and support arithmetic, comparisons, logical +/// operators, field references, registered scalar callbacks, and Access-style +/// built-in functions. /// public static class FormulaEvaluator { private static readonly string[] AggregateFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + private static readonly string[] DomainFunctionNames = ["DLOOKUP", "DCOUNT", "DSUM", "DAVG", "DMIN", "DMAX"]; + private static readonly HashSet BuiltInFunctionNameSet = + new(FormulaFunctionCatalog.BuiltInFunctionNames, StringComparer.OrdinalIgnoreCase); + + public static IReadOnlyCollection BuiltInFunctionNames => FormulaFunctionCatalog.BuiltInFunctionNames; /// - /// Evaluate a field-level formula (e.g., =Quantity * UnitPrice). - /// Returns null if any referenced field is null, formula is invalid, or division by zero. + /// Evaluate a field-level formula as a number. + /// Returns null if the formula is invalid, returns a nonnumeric value, or + /// hits an invalid numeric operation. /// public static double? Evaluate(string? formula, Func fieldResolver) + => Evaluate(formula, fieldResolver, DbFunctionRegistry.Empty); + + public static double? Evaluate( + string? formula, + Func fieldResolver, + DbFunctionRegistry? functions) + => Evaluate(formula, fieldResolver, functions, callbackPolicy: null); + + public static double? Evaluate( + string? formula, + Func fieldResolver, + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy) { - if (string.IsNullOrWhiteSpace(formula)) return null; + object? value = EvaluateValue( + formula, + field => fieldResolver(field), + functions, + callbackPolicy); + + return TryConvertDouble(value, out double numeric) ? numeric : null; + } + + public static object? EvaluateValue(string? formula, Func fieldResolver) + => EvaluateValue(formula, fieldResolver, DbFunctionRegistry.Empty); + + public static object? EvaluateValue( + string? formula, + Func fieldResolver, + DbFunctionRegistry? functions) + => EvaluateValue(formula, fieldResolver, functions, callbackPolicy: null); + + public static object? EvaluateValue( + string? formula, + Func fieldResolver, + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy, + FormulaDomainFunctionResolver? domainResolver = null) + { + if (string.IsNullOrWhiteSpace(formula)) + return null; + + string expr = formula.Trim(); + if (!expr.StartsWith('=')) + return null; - var expr = formula.Trim(); - if (!expr.StartsWith('=')) return null; expr = expr[1..].Trim(); - if (expr.Length == 0) return null; + if (expr.Length == 0) + return null; try { - var parser = new Parser(expr, fieldResolver); - var result = parser.ParseExpression(); - // Ensure we consumed all input - if (parser.Position < parser.Input.Length) - return null; - return result; + var parser = new Parser( + expr, + fieldResolver, + functions ?? DbFunctionRegistry.Empty, + callbackPolicy, + domainResolver); + object? result = parser.ParseExpression(); + parser.SkipWhitespace(); + return parser.Failed || parser.Position < parser.Input.Length ? null : NormalizeValue(result); } catch { @@ -41,6 +102,74 @@ public static class FormulaEvaluator } } + public static bool IsBuiltInFunctionName(string name) + => BuiltInFunctionNameSet.Contains(name); + + public static IReadOnlyList GetDomainReferences(string? formula) + { + string? expression = GetExpressionBody(formula); + if (expression is null) + return []; + + var domains = new HashSet(StringComparer.OrdinalIgnoreCase); + ReadOnlySpan input = expression.AsSpan(); + for (int i = 0; i < input.Length; i++) + { + char current = input[i]; + if (current is '\'' or '"') + { + i = SkipQuoted(input, i, current); + continue; + } + + if (current == '[') + { + i = SkipBracketed(input, i); + continue; + } + + if (!IsIdentifierStart(current)) + continue; + + int start = i; + i++; + while (i < input.Length && IsIdentifierPart(input[i])) + i++; + + string name = input[start..i].ToString(); + if (!DomainFunctionNames.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + i--; + continue; + } + + int cursor = i; + while (cursor < input.Length && char.IsWhiteSpace(input[cursor])) + cursor++; + + if (cursor >= input.Length || input[cursor] != '(') + { + i--; + continue; + } + + if (TryReadFunctionArguments(input, cursor, out string argumentsText, out int closeParen)) + { + string[] arguments = SplitTopLevelArguments(argumentsText); + if (arguments.Length >= 2 && TryReadLiteralText(arguments[1], out string? domain) && !string.IsNullOrWhiteSpace(domain)) + domains.Add(domain); + + i = closeParen; + } + else + { + i--; + } + } + + return domains.OrderBy(static domain => domain, StringComparer.OrdinalIgnoreCase).ToArray(); + } + /// /// Try to parse an aggregate formula like =SUM(TableName.FieldName). /// Returns true if the formula matches the aggregate pattern. @@ -54,7 +183,6 @@ public static bool TryParseAggregate(string? formula, out string func, out strin if (!expr.StartsWith('=')) return false; expr = expr[1..].Trim(); - // Match: FUNC(Table.Field) foreach (var fn in AggregateFunctions) { if (!expr.StartsWith(fn, StringComparison.OrdinalIgnoreCase)) continue; @@ -82,7 +210,7 @@ public static bool TryParseAggregate(string? formula, out string func, out strin /// /// Evaluate an aggregate function over a set of values. - /// Returns null if values is empty (except COUNT which returns 0). + /// Returns null if values is empty (except COUNT and SUM which return 0). /// public static double? EvaluateAggregate(string func, IEnumerable values) { @@ -99,151 +227,1507 @@ public static bool TryParseAggregate(string? formula, out string func, out strin }; } + private static string? GetExpressionBody(string? formula) + { + if (string.IsNullOrWhiteSpace(formula)) + return null; + + string expression = formula.Trim(); + if (!expression.StartsWith('=')) + return null; + + expression = expression[1..].Trim(); + return expression.Length == 0 ? null : expression; + } + private static bool IsValidIdentifier(string s) => s.Length > 0 && (char.IsLetter(s[0]) || s[0] == '_') && s.All(c => char.IsLetterOrDigit(c) || c == '_'); - /// - /// Recursive-descent parser for arithmetic expressions with field references. - /// Grammar: - /// Expression = Term (('+' | '-') Term)* - /// Term = Factor (('*' | '/') Factor)* - /// Factor = ['-'] Atom - /// Atom = Number | '(' Expression ')' | FieldName - /// private ref struct Parser { public ReadOnlySpan Input; public int Position; - private readonly Func _fieldResolver; + public bool Failed; - public Parser(string input, Func fieldResolver) + private readonly Func _fieldResolver; + private readonly DbFunctionRegistry _functions; + private readonly DbExtensionPolicy? _callbackPolicy; + private readonly FormulaDomainFunctionResolver? _domainResolver; + + public Parser( + string input, + Func fieldResolver, + DbFunctionRegistry functions, + DbExtensionPolicy? callbackPolicy, + FormulaDomainFunctionResolver? domainResolver) { Input = input.AsSpan(); Position = 0; + Failed = false; _fieldResolver = fieldResolver; + _functions = functions; + _callbackPolicy = callbackPolicy; + _domainResolver = domainResolver; } - public double? ParseExpression() + public object? ParseExpression() => ParseOr(); + + private object? ParseOr() { - var left = ParseTerm(); - while (Position < Input.Length) + object? left = ParseAnd(); + while (!Failed) + { + if (!MatchKeyword("OR")) + return left; + + object? right = ParseAnd(); + left = IsTruthy(left) || IsTruthy(right); + } + + return null; + } + + private object? ParseAnd() + { + object? left = ParseNot(); + while (!Failed) + { + if (!MatchKeyword("AND")) + return left; + + object? right = ParseNot(); + left = IsTruthy(left) && IsTruthy(right); + } + + return null; + } + + private object? ParseNot() + { + if (MatchKeyword("NOT")) + return !IsTruthy(ParseNot()); + + return ParseComparison(); + } + + private object? ParseComparison() + { + object? left = ParseAdditive(); + SkipWhitespace(); + string? op = MatchComparisonOperator(); + if (op is null) + return left; + + object? right = ParseAdditive(); + return Compare(left, right, op); + } + + private object? ParseAdditive() + { + object? left = ParseTerm(); + while (!Failed) { SkipWhitespace(); - if (Position >= Input.Length) break; - var ch = Input[Position]; - if (ch == '+') + if (Position >= Input.Length) + return left; + + char ch = Input[Position]; + if (ch == '+' || ch == '-') { Position++; - var right = ParseTerm(); - if (left is null || right is null) return null; - left = left.Value + right.Value; + object? right = ParseTerm(); + if (left is null || right is null) + { + left = null; + } + else if (TryConvertDouble(left, out double leftNumber) && TryConvertDouble(right, out double rightNumber)) + { + left = ch == '+' ? leftNumber + rightNumber : leftNumber - rightNumber; + } + else if (ch == '+') + { + left = ToFormulaString(left) + ToFormulaString(right); + } + else + { + Failed = true; + return null; + } + + continue; } - else if (ch == '-') + + if (ch == '&') { Position++; - var right = ParseTerm(); - if (left is null || right is null) return null; - left = left.Value - right.Value; - } - else - { - break; + object? right = ParseTerm(); + left = ToFormulaString(left) + ToFormulaString(right); + continue; } + + return left; } - return left; + + return null; } - private double? ParseTerm() + private object? ParseTerm() { - var left = ParseFactor(); - while (Position < Input.Length) + object? left = ParseFactor(); + while (!Failed) { SkipWhitespace(); - if (Position >= Input.Length) break; - var ch = Input[Position]; - if (ch == '*') + if (Position >= Input.Length) + return left; + + char ch = Input[Position]; + if (ch != '*' && ch != '/') + return left; + + Position++; + object? right = ParseFactor(); + if (left is null || right is null) { - Position++; - var right = ParseFactor(); - if (left is null || right is null) return null; - left = left.Value * right.Value; + left = null; + continue; } - else if (ch == '/') + + if (!TryConvertDouble(left, out double leftNumber) || !TryConvertDouble(right, out double rightNumber)) { - Position++; - var right = ParseFactor(); - if (left is null || right is null) return null; - if (right.Value == 0) return null; // Division by zero → null - left = left.Value / right.Value; + Failed = true; + return null; } - else + + if (ch == '/' && Math.Abs(rightNumber) < double.Epsilon) { - break; + left = null; + continue; } + + left = ch == '*' ? leftNumber * rightNumber : leftNumber / rightNumber; } - return left; + + return null; } - private double? ParseFactor() + private object? ParseFactor() { SkipWhitespace(); if (Position < Input.Length && Input[Position] == '-') { Position++; - var val = ParseAtom(); - return val.HasValue ? -val.Value : null; + object? value = ParseFactor(); + return TryConvertDouble(value, out double numeric) ? -numeric : null; + } + + if (Position < Input.Length && Input[Position] == '+') + { + Position++; + object? value = ParseFactor(); + return TryConvertDouble(value, out double numeric) ? numeric : null; } + return ParseAtom(); } - private double? ParseAtom() + private object? ParseAtom() { SkipWhitespace(); - if (Position >= Input.Length) return null; - - var ch = Input[Position]; + if (Position >= Input.Length) + { + Failed = true; + return null; + } - // Parenthesized expression + char ch = Input[Position]; if (ch == '(') { Position++; - var val = ParseExpression(); + object? value = ParseExpression(); SkipWhitespace(); if (Position < Input.Length && Input[Position] == ')') + { Position++; - else - return null; // Missing closing paren - return val; + return value; + } + + Failed = true; + return null; } - // Number literal + if (ch is '\'' or '"') + return ParseString(ch); + + if (ch == '[') + return ParseBracketedField(); + if (char.IsDigit(ch) || ch == '.') + return ParseNumber(); + + if (IsIdentifierStart(ch)) + { + string identifier = ParseIdentifier(); + if (string.Equals(identifier, "NULL", StringComparison.OrdinalIgnoreCase)) + return null; + if (string.Equals(identifier, "TRUE", StringComparison.OrdinalIgnoreCase)) + return true; + if (string.Equals(identifier, "FALSE", StringComparison.OrdinalIgnoreCase)) + return false; + + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == '(') + return ParseFunctionCall(identifier); + + return NormalizeValue(_fieldResolver(identifier)); + } + + Failed = true; + return null; + } + + private string ParseString(char quote) + { + Position++; + var builder = new System.Text.StringBuilder(); + while (Position < Input.Length) + { + char ch = Input[Position++]; + if (ch == quote) + { + if (Position < Input.Length && Input[Position] == quote) + { + builder.Append(quote); + Position++; + continue; + } + + return builder.ToString(); + } + + builder.Append(ch); + } + + Failed = true; + return string.Empty; + } + + private object? ParseBracketedField() + { + Position++; + int start = Position; + while (Position < Input.Length && Input[Position] != ']') + Position++; + + if (Position >= Input.Length) + { + Failed = true; + return null; + } + + string fieldName = Input[start..Position].ToString(); + Position++; + return NormalizeValue(_fieldResolver(fieldName)); + } + + private object? ParseNumber() + { + int start = Position; + bool hasDecimal = false; + while (Position < Input.Length) { - var start = Position; - while (Position < Input.Length && (char.IsDigit(Input[Position]) || Input[Position] == '.')) + char current = Input[Position]; + if (char.IsDigit(current)) + { + Position++; + continue; + } + + if (current == '.' && !hasDecimal) + { + hasDecimal = true; Position++; - var numStr = Input[start..Position].ToString(); - return double.TryParse(numStr, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out var num) ? num : null; + continue; + } + + break; + } + + ReadOnlySpan number = Input[start..Position]; + if (!hasDecimal && long.TryParse(number, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer)) + return integer; + + return double.TryParse(number, NumberStyles.Float, CultureInfo.InvariantCulture, out double real) + ? real + : Fail(); + } + + private string ParseIdentifier() + { + int start = Position; + Position++; + while (Position < Input.Length && IsIdentifierPart(Input[Position])) + Position++; + + return Input[start..Position].ToString(); + } + + private object? ParseFunctionCall(string functionName) + { + Position++; + var arguments = new List(); + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == ')') + { + Position++; + return InvokeFunction(functionName, arguments); } - // Field reference (identifier) - if (char.IsLetter(ch) || ch == '_') + while (!Failed && Position < Input.Length) { - var start = Position; - while (Position < Input.Length && (char.IsLetterOrDigit(Input[Position]) || Input[Position] == '_')) + arguments.Add(ParseExpression()); + + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == ',') + { + Position++; + continue; + } + + if (Position < Input.Length && Input[Position] == ')') + { Position++; - var fieldName = Input[start..Position].ToString(); - return _fieldResolver(fieldName); + return InvokeFunction(functionName, arguments); + } + + Failed = true; + return null; + } + + Failed = true; + return null; + } + + private object? InvokeFunction(string functionName, List arguments) + { + if (TryInvokeBuiltInFunction(functionName, arguments, _domainResolver, out object? builtInValue)) + return NormalizeValue(builtInValue); + + return InvokeRegisteredFunction(functionName, arguments); + } + + private object? InvokeRegisteredFunction(string functionName, List arguments) + { + if (!_functions.TryGetScalar(functionName, arguments.Count, out var definition)) + { + DbCallbackDiagnostics.WriteMissingScalarInvocation( + functionName, + arguments.Count, + CreateFormCallbackMetadata(functionName), + $"Unknown scalar function '{functionName}'."); + return null; } - return null; // Unexpected character + DbValue[] dbArguments = arguments.Select(ToDbValue).ToArray(); + if (definition.Options.NullPropagating && dbArguments.Any(static argument => argument.IsNull)) + return null; + + try + { + IReadOnlyDictionary? metadata = CreateFormCallbackMetadata(functionName); + DbValue value = _callbackPolicy is null + ? definition.Invoke(dbArguments, metadata) + : definition.Invoke(dbArguments, metadata, _callbackPolicy, DbExtensionHostMode.Embedded); + return FromDbValue(value); + } + catch + { + return null; + } } - private void SkipWhitespace() + public void SkipWhitespace() { while (Position < Input.Length && char.IsWhiteSpace(Input[Position])) Position++; } + + private bool MatchKeyword(string keyword) + { + SkipWhitespace(); + if (!Input[Position..].StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) + return false; + + int end = Position + keyword.Length; + if (end < Input.Length && IsIdentifierPart(Input[end])) + return false; + + Position = end; + return true; + } + + private string? MatchComparisonOperator() + { + SkipWhitespace(); + foreach (string op in new[] { ">=", "<=", "==", "!=", "<>", "=", ">", "<" }) + { + if (Input[Position..].StartsWith(op, StringComparison.Ordinal)) + { + Position += op.Length; + return op; + } + } + + return null; + } + + private object? Fail() + { + Failed = true; + return null; + } + } + + private static bool TryInvokeBuiltInFunction( + string functionName, + IReadOnlyList args, + FormulaDomainFunctionResolver? domainResolver, + out object? value) + { + value = null; + switch (functionName.ToUpperInvariant()) + { + case "NZ": + value = args.Count is 1 or 2 + ? IsNullOrEmpty(args[0]) ? args.Count == 2 ? args[1] : string.Empty : args[0] + : null; + return args.Count is 1 or 2; + case "ISNULL": + value = args.Count == 1 && NormalizeValue(args[0]) is null; + return args.Count == 1; + case "ISEMPTY": + value = args.Count == 1 && IsNullOrEmpty(args[0]); + return args.Count == 1; + case "IIF": + value = args.Count == 3 ? IsTruthy(args[0]) ? args[1] : args[2] : null; + return args.Count == 3; + case "SWITCH": + if (args.Count == 0 || args.Count % 2 != 0) + return true; + for (int i = 0; i < args.Count; i += 2) + { + if (IsTruthy(args[i])) + { + value = args[i + 1]; + return true; + } + } + return true; + case "CHOOSE": + if (args.Count < 2 || !TryConvertLong(args[0], out long index)) + return true; + value = index >= 1 && index < args.Count ? args[(int)index] : null; + return true; + case "LEN": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).Length : null; + return args.Count == 1; + case "LEFT": + value = args.Count == 2 && TryConvertLong(args[1], out long leftCount) + ? Left(ToFormulaString(args[0]), leftCount) + : null; + return args.Count == 2; + case "RIGHT": + value = args.Count == 2 && TryConvertLong(args[1], out long rightCount) + ? Right(ToFormulaString(args[0]), rightCount) + : null; + return args.Count == 2; + case "MID": + value = EvaluateMid(args); + return args.Count is 2 or 3; + case "TRIM": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).Trim() : null; + return args.Count == 1; + case "LTRIM": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).TrimStart() : null; + return args.Count == 1; + case "RTRIM": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).TrimEnd() : null; + return args.Count == 1; + case "UCASE": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).ToUpperInvariant() : null; + return args.Count == 1; + case "LCASE": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).ToLowerInvariant() : null; + return args.Count == 1; + case "INSTR": + value = EvaluateInStr(args); + return args.Count is 2 or 3; + case "REPLACE": + value = args.Count == 3 && args[0] is not null && args[1] is not null + ? ToFormulaString(args[0]).Replace(ToFormulaString(args[1]), ToFormulaString(args[2]), StringComparison.Ordinal) + : null; + return args.Count == 3; + case "STRCOMP": + value = EvaluateStrComp(args); + return args.Count is 2 or 3; + case "VAL": + value = args.Count == 1 ? ParseLeadingNumber(ToFormulaString(args[0])) : null; + return args.Count == 1; + case "DATE": + value = args.Count == 0 ? DateTime.Now.Date : null; + return args.Count == 0; + case "TIME": + value = args.Count == 0 ? DateTime.Now.TimeOfDay : null; + return args.Count == 0; + case "NOW": + value = args.Count == 0 ? DateTime.Now : null; + return args.Count == 0; + case "YEAR": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime yearDate) ? yearDate.Year : null; + return args.Count == 1; + case "MONTH": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime monthDate) ? monthDate.Month : null; + return args.Count == 1; + case "DAY": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime dayDate) ? dayDate.Day : null; + return args.Count == 1; + case "HOUR": + value = args.Count == 1 && TryConvertTime(args[0], out TimeSpan hourTime) ? hourTime.Hours : null; + return args.Count == 1; + case "MINUTE": + value = args.Count == 1 && TryConvertTime(args[0], out TimeSpan minuteTime) ? minuteTime.Minutes : null; + return args.Count == 1; + case "SECOND": + value = args.Count == 1 && TryConvertTime(args[0], out TimeSpan secondTime) ? secondTime.Seconds : null; + return args.Count == 1; + case "DATEADD": + value = EvaluateDateAdd(args); + return args.Count == 3; + case "DATEDIFF": + value = EvaluateDateDiff(args); + return args.Count == 3; + case "DATEPART": + value = EvaluateDatePart(args); + return args.Count == 2; + case "DATESERIAL": + value = EvaluateDateSerial(args); + return args.Count == 3; + case "TIMESERIAL": + value = EvaluateTimeSerial(args); + return args.Count == 3; + case "WEEKDAY": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime weekdayDate) + ? ((int)weekdayDate.DayOfWeek) + 1 + : null; + return args.Count == 1; + case "MONTHNAME": + value = EvaluateMonthName(args); + return args.Count is 1 or 2; + case "ABS": + value = args.Count == 1 && TryConvertDouble(args[0], out double absValue) ? Math.Abs(absValue) : null; + return args.Count == 1; + case "ROUND": + value = EvaluateRound(args); + return args.Count is 1 or 2; + case "INT": + value = args.Count == 1 && TryConvertDouble(args[0], out double intValue) ? Math.Floor(intValue) : null; + return args.Count == 1; + case "FIX": + value = args.Count == 1 && TryConvertDouble(args[0], out double fixValue) ? Math.Truncate(fixValue) : null; + return args.Count == 1; + case "SGN": + value = args.Count == 1 && TryConvertDouble(args[0], out double sgnValue) ? Math.Sign(sgnValue) : null; + return args.Count == 1; + case "CSTR": + value = args.Count == 1 ? ToFormulaString(args[0]) : null; + return args.Count == 1; + case "CINT": + case "CLNG": + value = args.Count == 1 && TryConvertDouble(args[0], out double integerValue) + ? Convert.ToInt64(Math.Round(integerValue, MidpointRounding.ToEven), CultureInfo.InvariantCulture) + : null; + return args.Count == 1; + case "CDBL": + value = args.Count == 1 && TryConvertDouble(args[0], out double doubleValue) ? doubleValue : null; + return args.Count == 1; + case "CBOOL": + value = args.Count == 1 && TryConvertBoolean(args[0], out bool boolValue) ? boolValue : null; + return args.Count == 1; + case "CDATE": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime dateValue) ? dateValue : null; + return args.Count == 1; + case "FORMAT": + value = args.Count == 2 ? FormatValue(args[0], ToFormulaString(args[1])) : null; + return args.Count == 2; + case "DLOOKUP": + case "DCOUNT": + case "DSUM": + case "DAVG": + case "DMIN": + case "DMAX": + value = EvaluateDomainFunction(functionName, args, domainResolver); + return args.Count is 2 or 3; + default: + return false; + } + } + + private static object? EvaluateMid(IReadOnlyList args) + { + if (args.Count is not (2 or 3) || args[0] is null || !TryConvertLong(args[1], out long start)) + return null; + + string value = ToFormulaString(args[0]); + int zeroBasedStart = Math.Max(0, (int)start - 1); + if (zeroBasedStart >= value.Length) + return string.Empty; + + if (args.Count == 2) + return value[zeroBasedStart..]; + + if (!TryConvertLong(args[2], out long count)) + return null; + + int length = Math.Clamp((int)count, 0, value.Length - zeroBasedStart); + return value.Substring(zeroBasedStart, length); + } + + private static object? EvaluateInStr(IReadOnlyList args) + { + if (args.Count is not (2 or 3)) + return null; + + long start = 1; + object? source = args[0]; + object? search = args[1]; + if (args.Count == 3) + { + if (!TryConvertLong(args[0], out start)) + return null; + + source = args[1]; + search = args[2]; + } + + if (source is null || search is null) + return null; + + string sourceText = ToFormulaString(source); + string searchText = ToFormulaString(search); + int zeroBasedStart = Math.Clamp((int)start - 1, 0, sourceText.Length); + int index = sourceText.IndexOf(searchText, zeroBasedStart, StringComparison.OrdinalIgnoreCase); + return index < 0 ? 0 : index + 1; } + + private static object? EvaluateStrComp(IReadOnlyList args) + { + if (args.Count is not (2 or 3) || args[0] is null || args[1] is null) + return null; + + StringComparison comparison = StringComparison.Ordinal; + if (args.Count == 3) + { + string mode = ToFormulaString(args[2]); + if (string.Equals(mode, "text", StringComparison.OrdinalIgnoreCase) || + string.Equals(mode, "1", StringComparison.OrdinalIgnoreCase)) + { + comparison = StringComparison.OrdinalIgnoreCase; + } + } + + int result = string.Compare(ToFormulaString(args[0]), ToFormulaString(args[1]), comparison); + return result < 0 ? -1 : result > 0 ? 1 : 0; + } + + private static object? EvaluateDateAdd(IReadOnlyList args) + { + if (args.Count != 3 || + !TryConvertLong(args[1], out long amount) || + !TryConvertDateTime(args[2], out DateTime date)) + { + return null; + } + + return AddDateInterval(ToFormulaString(args[0]), date, amount); + } + + private static object? EvaluateDateDiff(IReadOnlyList args) + { + if (args.Count != 3 || + !TryConvertDateTime(args[1], out DateTime start) || + !TryConvertDateTime(args[2], out DateTime end)) + { + return null; + } + + return DiffDateInterval(ToFormulaString(args[0]), start, end); + } + + private static object? EvaluateDatePart(IReadOnlyList args) + { + if (args.Count != 2 || !TryConvertDateTime(args[1], out DateTime date)) + return null; + + return GetDatePart(ToFormulaString(args[0]), date); + } + + private static object? EvaluateDateSerial(IReadOnlyList args) + { + if (args.Count != 3 || + !TryConvertLong(args[0], out long year) || + !TryConvertLong(args[1], out long month) || + !TryConvertLong(args[2], out long day)) + { + return null; + } + + try + { + return new DateTime((int)year, 1, 1).AddMonths((int)month - 1).AddDays((int)day - 1); + } + catch + { + return null; + } + } + + private static object? EvaluateTimeSerial(IReadOnlyList args) + { + if (args.Count != 3 || + !TryConvertLong(args[0], out long hour) || + !TryConvertLong(args[1], out long minute) || + !TryConvertLong(args[2], out long second)) + { + return null; + } + + try + { + return TimeSpan.FromHours(hour) + TimeSpan.FromMinutes(minute) + TimeSpan.FromSeconds(second); + } + catch + { + return null; + } + } + + private static object? EvaluateMonthName(IReadOnlyList args) + { + if (args.Count is not (1 or 2) || !TryConvertLong(args[0], out long month) || month is < 1 or > 12) + return null; + + bool abbreviate = args.Count == 2 && TryConvertBoolean(args[1], out bool abbreviated) && abbreviated; + DateTimeFormatInfo format = CultureInfo.InvariantCulture.DateTimeFormat; + return abbreviate ? format.GetAbbreviatedMonthName((int)month) : format.GetMonthName((int)month); + } + + private static object? EvaluateRound(IReadOnlyList args) + { + if (args.Count is not (1 or 2) || !TryConvertDouble(args[0], out double value)) + return null; + + int digits = 0; + if (args.Count == 2) + { + if (!TryConvertLong(args[1], out long parsedDigits) || parsedDigits is < 0 or > 15) + return null; + + digits = (int)parsedDigits; + } + + return Math.Round(value, digits, MidpointRounding.ToEven); + } + + private static object? EvaluateDomainFunction( + string functionName, + IReadOnlyList args, + FormulaDomainFunctionResolver? domainResolver) + { + if (args.Count is not (2 or 3) || domainResolver is null) + return null; + + string expression = ToFormulaString(args[0]).Trim(); + string domain = ToFormulaString(args[1]).Trim(); + string? criteria = args.Count == 3 ? ToFormulaString(args[2]) : null; + if (string.IsNullOrWhiteSpace(expression) || string.IsNullOrWhiteSpace(domain)) + return null; + + return domainResolver(new FormulaDomainFunctionRequest( + functionName.ToUpperInvariant(), + expression, + domain, + string.IsNullOrWhiteSpace(criteria) ? null : criteria)); + } + + private static DateTime? AddDateInterval(string interval, DateTime date, long amount) + { + try + { + return NormalizeInterval(interval) switch + { + "yyyy" => date.AddYears((int)amount), + "q" => date.AddMonths((int)amount * 3), + "m" => date.AddMonths((int)amount), + "y" or "d" or "w" => date.AddDays(amount), + "ww" => date.AddDays(amount * 7), + "h" => date.AddHours(amount), + "n" => date.AddMinutes(amount), + "s" => date.AddSeconds(amount), + _ => null, + }; + } + catch + { + return null; + } + } + + private static long? DiffDateInterval(string interval, DateTime start, DateTime end) + => NormalizeInterval(interval) switch + { + "yyyy" => end.Year - start.Year, + "q" => ((end.Year - start.Year) * 4) + ((end.Month - 1) / 3) - ((start.Month - 1) / 3), + "m" => ((end.Year - start.Year) * 12) + end.Month - start.Month, + "y" or "d" or "w" => (long)(end.Date - start.Date).TotalDays, + "ww" => (long)Math.Floor((end.Date - start.Date).TotalDays / 7), + "h" => (long)(end - start).TotalHours, + "n" => (long)(end - start).TotalMinutes, + "s" => (long)(end - start).TotalSeconds, + _ => null, + }; + + private static long? GetDatePart(string interval, DateTime date) + => NormalizeInterval(interval) switch + { + "yyyy" => date.Year, + "q" => ((date.Month - 1) / 3) + 1, + "m" => date.Month, + "y" => date.DayOfYear, + "d" => date.Day, + "w" => ((int)date.DayOfWeek) + 1, + "ww" => ISOWeek.GetWeekOfYear(date), + "h" => date.Hour, + "n" => date.Minute, + "s" => date.Second, + _ => null, + }; + + private static string NormalizeInterval(string interval) + => interval.Trim().Trim('"', '\'').ToLowerInvariant(); + + private static string Left(string value, long count) + { + int length = Math.Clamp((int)count, 0, value.Length); + return value[..length]; + } + + private static string Right(string value, long count) + { + int length = Math.Clamp((int)count, 0, value.Length); + return value[(value.Length - length)..]; + } + + private static double ParseLeadingNumber(string text) + { + text = text.TrimStart(); + if (text.Length == 0) + return 0; + + int index = 0; + if (text[index] is '+' or '-') + index++; + + bool hasDigit = false; + bool hasDecimal = false; + while (index < text.Length) + { + char ch = text[index]; + if (char.IsDigit(ch)) + { + hasDigit = true; + index++; + continue; + } + + if (ch == '.' && !hasDecimal) + { + hasDecimal = true; + index++; + continue; + } + + break; + } + + if (!hasDigit) + return 0; + + if (index < text.Length && text[index] is 'e' or 'E') + { + int exponentStart = index; + index++; + if (index < text.Length && text[index] is '+' or '-') + index++; + + bool hasExponentDigit = false; + while (index < text.Length && char.IsDigit(text[index])) + { + hasExponentDigit = true; + index++; + } + + if (!hasExponentDigit) + index = exponentStart; + } + + return double.TryParse(text[..index], NumberStyles.Float, CultureInfo.InvariantCulture, out double result) + ? result + : 0; + } + + private static object? FormatValue(object? value, string format) + { + value = NormalizeValue(value); + if (value is null) + return null; + + try + { + if (value is DateTime dateTime) + return dateTime.ToString(format, CultureInfo.InvariantCulture); + + if (value is TimeSpan time) + return time.ToString(format, CultureInfo.InvariantCulture); + + if (TryConvertDouble(value, out double number)) + return number.ToString(format, CultureInfo.InvariantCulture); + + if (value is IFormattable formattable) + return formattable.ToString(format, CultureInfo.InvariantCulture); + } + catch + { + return ToFormulaString(value); + } + + return ToFormulaString(value); + } + + private static bool Compare(object? left, object? right, string op) + { + int comparison = CompareValues(left, right); + return op switch + { + "=" or "==" => comparison == 0, + "!=" or "<>" => comparison != 0, + ">" => comparison > 0, + ">=" => comparison >= 0, + "<" => comparison < 0, + "<=" => comparison <= 0, + _ => false, + }; + } + + private static int CompareValues(object? left, object? right) + { + left = NormalizeValue(left); + right = NormalizeValue(right); + + if (left is null || right is null) + return left is null && right is null ? 0 : left is null ? -1 : 1; + + if (TryConvertDouble(left, out double leftNumber) && TryConvertDouble(right, out double rightNumber)) + return leftNumber.CompareTo(rightNumber); + + if (TryConvertDateTime(left, out DateTime leftDate) && TryConvertDateTime(right, out DateTime rightDate)) + return leftDate.CompareTo(rightDate); + + if (TryConvertBoolean(left, out bool leftBool) && TryConvertBoolean(right, out bool rightBool)) + return leftBool.CompareTo(rightBool); + + return string.Compare(ToFormulaString(left), ToFormulaString(right), StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTruthy(object? value) + { + value = NormalizeValue(value); + if (value is null) + return false; + + if (value is bool boolean) + return boolean; + + if (TryConvertDouble(value, out double number)) + return Math.Abs(number) > double.Epsilon; + + string text = ToFormulaString(value); + if (bool.TryParse(text, out bool parsed)) + return parsed; + + return !string.IsNullOrWhiteSpace(text); + } + + private static bool IsNullOrEmpty(object? value) + { + value = NormalizeValue(value); + return value is null || value is string text && text.Length == 0; + } + + private static bool TryConvertDouble(object? value, out double result) + { + value = NormalizeValue(value); + switch (value) + { + case byte number: + result = number; + return true; + case sbyte number: + result = number; + return true; + case short number: + result = number; + return true; + case ushort number: + result = number; + return true; + case int number: + result = number; + return true; + case uint number: + result = number; + return true; + case long number: + result = number; + return true; + case ulong number: + result = number; + return true; + case float number: + result = number; + return true; + case double number: + result = number; + return true; + case decimal number: + result = (double)number; + return true; + case bool boolean: + result = boolean ? 1 : 0; + return true; + case string text: + return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out result); + default: + result = 0; + return false; + } + } + + private static bool TryConvertLong(object? value, out long result) + { + value = NormalizeValue(value); + switch (value) + { + case byte number: + result = number; + return true; + case sbyte number: + result = number; + return true; + case short number: + result = number; + return true; + case ushort number: + result = number; + return true; + case int number: + result = number; + return true; + case uint number: + result = number; + return true; + case long number: + result = number; + return true; + case float or double or decimal: + if (TryConvertDouble(value, out double numeric)) + { + result = Convert.ToInt64(Math.Round(numeric, MidpointRounding.ToEven), CultureInfo.InvariantCulture); + return true; + } + + break; + case string text when long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer): + result = integer; + return true; + case string text when double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double real): + result = Convert.ToInt64(Math.Round(real, MidpointRounding.ToEven), CultureInfo.InvariantCulture); + return true; + } + + result = 0; + return false; + } + + private static bool TryConvertBoolean(object? value, out bool result) + { + value = NormalizeValue(value); + switch (value) + { + case bool boolean: + result = boolean; + return true; + case string text: + if (bool.TryParse(text, out result)) + return true; + if (string.Equals(text, "yes", StringComparison.OrdinalIgnoreCase) || + string.Equals(text, "y", StringComparison.OrdinalIgnoreCase)) + { + result = true; + return true; + } + if (string.Equals(text, "no", StringComparison.OrdinalIgnoreCase) || + string.Equals(text, "n", StringComparison.OrdinalIgnoreCase)) + { + result = false; + return true; + } + if (TryConvertDouble(text, out double numericText)) + { + result = Math.Abs(numericText) > double.Epsilon; + return true; + } + break; + default: + if (TryConvertDouble(value, out double numeric)) + { + result = Math.Abs(numeric) > double.Epsilon; + return true; + } + break; + } + + result = false; + return false; + } + + private static bool TryConvertDateTime(object? value, out DateTime result) + { + value = NormalizeValue(value); + switch (value) + { + case DateTime dateTime: + result = dateTime; + return true; + case DateTimeOffset dateTimeOffset: + result = dateTimeOffset.LocalDateTime; + return true; + case DateOnly dateOnly: + result = dateOnly.ToDateTime(TimeOnly.MinValue); + return true; + case string text: + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out result) || + DateTime.TryParse(text, CultureInfo.CurrentCulture, DateTimeStyles.AllowWhiteSpaces, out result)) + { + return true; + } + break; + default: + if (TryConvertDouble(value, out double numeric)) + { + try + { + result = DateTime.FromOADate(numeric); + return true; + } + catch + { + } + } + break; + } + + result = default; + return false; + } + + private static bool TryConvertTime(object? value, out TimeSpan result) + { + value = NormalizeValue(value); + switch (value) + { + case TimeSpan timeSpan: + result = timeSpan; + return true; + case TimeOnly timeOnly: + result = timeOnly.ToTimeSpan(); + return true; + case DateTime dateTime: + result = dateTime.TimeOfDay; + return true; + case DateTimeOffset dateTimeOffset: + result = dateTimeOffset.TimeOfDay; + return true; + case string text: + if (TimeSpan.TryParse(text, CultureInfo.InvariantCulture, out result)) + return true; + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out DateTime parsedDate)) + { + result = parsedDate.TimeOfDay; + return true; + } + break; + } + + result = default; + return false; + } + + private static string ToFormulaString(object? value) + { + value = NormalizeValue(value); + return value switch + { + null => string.Empty, + DateTime dateTime => dateTime.TimeOfDay == TimeSpan.Zero + ? dateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + : dateTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + TimeSpan timeSpan => timeSpan.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), + bool boolean => boolean ? "True" : "False", + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, + }; + } + + private static object? NormalizeValue(object? value) + => value is JsonElement json ? NormalizeJsonValue(json) : value; + + private static object? NormalizeJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number when value.TryGetInt64(out long integer) => integer, + JsonValueKind.Number => value.GetDouble(), + _ => value.ToString(), + }; + + private static DbValue ToDbValue(object? value) + { + value = NormalizeValue(value); + return value switch + { + null => DbValue.Null, + DbValue dbValue => dbValue, + bool boolean => DbValue.FromInteger(boolean ? 1 : 0), + byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + string text => DbValue.FromText(text), + byte[] bytes => DbValue.FromBlob(bytes), + DateTime or DateTimeOffset or DateOnly or TimeOnly or TimeSpan => DbValue.FromText(ToFormulaString(value)), + _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; + } + + private static object? FromDbValue(DbValue value) => value.Type switch + { + DbType.Null => null, + DbType.Integer => value.AsInteger, + DbType.Real => value.AsReal, + DbType.Text => value.AsText, + DbType.Blob => value.AsBlob, + _ => null, + }; + + private static bool TryReadFunctionArguments( + ReadOnlySpan input, + int openParen, + out string argumentsText, + out int closeParen) + { + int depth = 0; + for (int i = openParen; i < input.Length; i++) + { + char ch = input[i]; + if (ch is '\'' or '"') + { + i = SkipQuoted(input, i, ch); + continue; + } + + if (ch == '[') + { + i = SkipBracketed(input, i); + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')') + { + depth--; + if (depth == 0) + { + argumentsText = input[(openParen + 1)..i].ToString(); + closeParen = i; + return true; + } + } + } + + argumentsText = string.Empty; + closeParen = -1; + return false; + } + + private static string[] SplitTopLevelArguments(string argumentsText) + { + if (string.IsNullOrWhiteSpace(argumentsText)) + return []; + + var arguments = new List(); + int start = 0; + int depth = 0; + bool inBracket = false; + char quote = '\0'; + for (int i = 0; i < argumentsText.Length; i++) + { + char ch = argumentsText[i]; + if (quote != '\0') + { + if (ch == quote) + { + if (i + 1 < argumentsText.Length && argumentsText[i + 1] == quote) + { + i++; + continue; + } + + quote = '\0'; + } + + continue; + } + + if (inBracket) + { + if (ch == ']') + inBracket = false; + continue; + } + + if (ch is '\'' or '"') + { + quote = ch; + continue; + } + + if (ch == '[') + { + inBracket = true; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')') + { + depth--; + continue; + } + + if (ch == ',' && depth == 0) + { + arguments.Add(argumentsText[start..i].Trim()); + start = i + 1; + } + } + + arguments.Add(argumentsText[start..].Trim()); + return arguments.Any(static argument => argument.Length == 0) ? [] : arguments.ToArray(); + } + + private static bool TryReadLiteralText(string token, out string? value) + { + value = null; + token = token.Trim(); + if (token.Length == 0) + return false; + + if (token.Length >= 2 && token[0] is '\'' or '"' && token[^1] == token[0]) + { + char quote = token[0]; + value = token[1..^1].Replace($"{quote}{quote}", quote.ToString(), StringComparison.Ordinal); + return true; + } + + if (token.StartsWith('[') && token.EndsWith(']') && token.Length > 2) + { + value = token[1..^1].Trim(); + return value.Length > 0; + } + + if (IsValidIdentifier(token)) + { + value = token; + return true; + } + + return false; + } + + private static int SkipQuoted(ReadOnlySpan input, int start, char quote) + { + for (int i = start + 1; i < input.Length; i++) + { + if (input[i] != quote) + continue; + + if (i + 1 < input.Length && input[i + 1] == quote) + { + i++; + continue; + } + + return i; + } + + return input.Length - 1; + } + + private static int SkipBracketed(ReadOnlySpan input, int start) + { + for (int i = start + 1; i < input.Length; i++) + { + if (input[i] == ']') + return i; + } + + return input.Length - 1; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; + + private static bool IsIdentifierPart(char value) + => char.IsLetterOrDigit(value) || value == '_'; + + private static IReadOnlyDictionary? CreateFormCallbackMetadata(string functionName) + => DbCallbackDiagnostics.IsInvocationEnabled + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminForms", + ["location"] = $"formulas.functions.{functionName}", + ["correlationId"] = Guid.NewGuid().ToString("N"), + } + : null; } diff --git a/src/CSharpDB.Admin.Forms/Evaluation/FormulaFunctionCatalog.cs b/src/CSharpDB.Admin.Forms/Evaluation/FormulaFunctionCatalog.cs new file mode 100644 index 00000000..dd7214ef --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Evaluation/FormulaFunctionCatalog.cs @@ -0,0 +1,94 @@ +namespace CSharpDB.Admin.Forms.Evaluation; + +public sealed record FormulaFunctionDescriptor( + string Name, + string Category, + string Signature, + string Description, + string Example, + string InsertText); + +public static class FormulaFunctionCatalog +{ + public static IReadOnlyList ExpressionFunctions { get; } = + [ + new("Nz", "Null and Conditional", "Nz(value, fallback)", "Returns fallback when value is null or empty.", "=Nz(Quantity, 0)", "Nz(value, fallback)"), + new("IsNull", "Null and Conditional", "IsNull(value)", "Returns true when value is null.", "=IsNull(ClosedDate)", "IsNull(value)"), + new("IsEmpty", "Null and Conditional", "IsEmpty(value)", "Returns true when value is null or empty text.", "=IsEmpty(Notes)", "IsEmpty(value)"), + new("IIf", "Null and Conditional", "IIf(condition, trueValue, falseValue)", "Returns one of two values based on a condition.", "=IIf(Status = 'Closed', 'Done', 'Open')", "IIf(condition, trueValue, falseValue)"), + new("Switch", "Null and Conditional", "Switch(condition1, value1, ...)", "Returns the first value whose condition is true.", "=Switch(Priority = 1, 'High', Priority = 2, 'Normal')", "Switch(condition1, value1, condition2, value2)"), + new("Choose", "Null and Conditional", "Choose(index, value1, value2, ...)", "Returns the value at a 1-based position.", "=Choose(StatusCode, 'New', 'Open', 'Closed')", "Choose(index, value1, value2)"), + + new("Len", "Text", "Len(value)", "Returns text length.", "=Len(CustomerName)", "Len(value)"), + new("Left", "Text", "Left(value, count)", "Returns characters from the left side.", "=Left(CustomerCode, 3)", "Left(value, count)"), + new("Right", "Text", "Right(value, count)", "Returns characters from the right side.", "=Right(OrderNumber, 4)", "Right(value, count)"), + new("Mid", "Text", "Mid(value, start, count)", "Returns characters from the middle of text.", "=Mid(ProductCode, 2, 3)", "Mid(value, start, count)"), + new("Trim", "Text", "Trim(value)", "Trims leading and trailing spaces.", "=Trim(CustomerName)", "Trim(value)"), + new("LTrim", "Text", "LTrim(value)", "Trims leading spaces.", "=LTrim(Code)", "LTrim(value)"), + new("RTrim", "Text", "RTrim(value)", "Trims trailing spaces.", "=RTrim(Code)", "RTrim(value)"), + new("UCase", "Text", "UCase(value)", "Converts text to uppercase.", "=UCase(CustomerName)", "UCase(value)"), + new("LCase", "Text", "LCase(value)", "Converts text to lowercase.", "=LCase(Email)", "LCase(value)"), + new("InStr", "Text", "InStr(value, search)", "Returns the 1-based position of search text, or 0.", "=InStr(Email, '@')", "InStr(value, search)"), + new("Replace", "Text", "Replace(value, search, replacement)", "Replaces matching text.", "=Replace(Phone, '-', '')", "Replace(value, search, replacement)"), + new("StrComp", "Text", "StrComp(left, right, comparison)", "Compares two strings and returns -1, 0, or 1.", "=StrComp(Code, 'A1', 'text')", "StrComp(left, right, 'text')"), + new("Val", "Text", "Val(value)", "Parses leading numeric text.", "=Val(QuantityText)", "Val(value)"), + + new("Date", "Date and Time", "Date()", "Returns today's date.", "=Date()", "Date()"), + new("Time", "Date and Time", "Time()", "Returns the current time.", "=Time()", "Time()"), + new("Now", "Date and Time", "Now()", "Returns the current date and time.", "=Now()", "Now()"), + new("Year", "Date and Time", "Year(value)", "Returns the year number.", "=Year(OrderDate)", "Year(value)"), + new("Month", "Date and Time", "Month(value)", "Returns the month number.", "=Month(OrderDate)", "Month(value)"), + new("Day", "Date and Time", "Day(value)", "Returns the day of month.", "=Day(OrderDate)", "Day(value)"), + new("Hour", "Date and Time", "Hour(value)", "Returns the hour.", "=Hour(UpdatedAt)", "Hour(value)"), + new("Minute", "Date and Time", "Minute(value)", "Returns the minute.", "=Minute(UpdatedAt)", "Minute(value)"), + new("Second", "Date and Time", "Second(value)", "Returns the second.", "=Second(UpdatedAt)", "Second(value)"), + new("DateAdd", "Date and Time", "DateAdd(interval, amount, value)", "Adds a date/time interval.", "=DateAdd('d', 7, OrderDate)", "DateAdd('d', amount, value)"), + new("DateDiff", "Date and Time", "DateDiff(interval, start, end)", "Returns the difference between dates.", "=DateDiff('d', OrderDate, Date())", "DateDiff('d', start, end)"), + new("DatePart", "Date and Time", "DatePart(interval, value)", "Returns a date/time part.", "=DatePart('q', OrderDate)", "DatePart('q', value)"), + new("DateSerial", "Date and Time", "DateSerial(year, month, day)", "Builds a date.", "=DateSerial(Year(Date()), 1, 1)", "DateSerial(year, month, day)"), + new("TimeSerial", "Date and Time", "TimeSerial(hour, minute, second)", "Builds a time value.", "=TimeSerial(17, 0, 0)", "TimeSerial(hour, minute, second)"), + new("Weekday", "Date and Time", "Weekday(value)", "Returns day of week as 1 through 7.", "=Weekday(OrderDate)", "Weekday(value)"), + new("MonthName", "Date and Time", "MonthName(month)", "Returns the month name.", "=MonthName(Month(OrderDate))", "MonthName(month)"), + + new("Abs", "Number and Conversion", "Abs(value)", "Returns absolute value.", "=Abs(Balance)", "Abs(value)"), + new("Round", "Number and Conversion", "Round(value, digits)", "Rounds a number.", "=Round(Amount, 2)", "Round(value, digits)"), + new("Int", "Number and Conversion", "Int(value)", "Rounds down to an integer.", "=Int(Amount)", "Int(value)"), + new("Fix", "Number and Conversion", "Fix(value)", "Truncates toward zero.", "=Fix(Amount)", "Fix(value)"), + new("Sgn", "Number and Conversion", "Sgn(value)", "Returns -1, 0, or 1.", "=Sgn(Balance)", "Sgn(value)"), + new("CStr", "Number and Conversion", "CStr(value)", "Converts a value to text.", "=CStr(OrderId)", "CStr(value)"), + new("CInt", "Number and Conversion", "CInt(value)", "Converts a value to an integer.", "=CInt(QuantityText)", "CInt(value)"), + new("CLng", "Number and Conversion", "CLng(value)", "Converts a value to a long integer.", "=CLng(IdText)", "CLng(value)"), + new("CDbl", "Number and Conversion", "CDbl(value)", "Converts a value to a double.", "=CDbl(AmountText)", "CDbl(value)"), + new("CBool", "Number and Conversion", "CBool(value)", "Converts a value to boolean.", "=CBool(IsActive)", "CBool(value)"), + new("CDate", "Number and Conversion", "CDate(value)", "Converts a value to date/time.", "=CDate(DateText)", "CDate(value)"), + new("Format", "Number and Conversion", "Format(value, format)", "Formats a number, date, time, or text.", "=Format(Amount, '0.00')", "Format(value, format)"), + + new("DLookup", "Domain", "DLookup(expr, domain, criteria)", "Returns one value from another table/query.", "=DLookup('Name', 'Customers', 'CustomerId = 1')", "DLookup('FieldName', 'TableName', 'Field = value')"), + new("DCount", "Domain", "DCount(expr, domain, criteria)", "Counts matching rows in another table/query.", "=DCount('*', 'Orders', 'Status = ''Open''')", "DCount('*', 'TableName', 'Field = value')"), + new("DSum", "Domain", "DSum(expr, domain, criteria)", "Sums matching rows in another table/query.", "=DSum('Amount', 'OrderLines', 'OrderId = 1')", "DSum('FieldName', 'TableName', 'Field = value')"), + new("DAvg", "Domain", "DAvg(expr, domain, criteria)", "Averages matching rows in another table/query.", "=DAvg('Amount', 'OrderLines', 'OrderId = 1')", "DAvg('FieldName', 'TableName', 'Field = value')"), + new("DMin", "Domain", "DMin(expr, domain, criteria)", "Returns the minimum matching value.", "=DMin('Amount', 'OrderLines', 'OrderId = 1')", "DMin('FieldName', 'TableName', 'Field = value')"), + new("DMax", "Domain", "DMax(expr, domain, criteria)", "Returns the maximum matching value.", "=DMax('Amount', 'OrderLines', 'OrderId = 1')", "DMax('FieldName', 'TableName', 'Field = value')"), + ]; + + public static IReadOnlyList AggregateFunctions { get; } = + [ + new("SUM", "Child Aggregates", "SUM(Table.Field)", "Sums child-row values for the current parent record.", "=SUM(OrderLines.Amount)", "SUM(Table.Field)"), + new("COUNT", "Child Aggregates", "COUNT(Table.Field)", "Counts child-row values for the current parent record.", "=COUNT(OrderLines.Id)", "COUNT(Table.Field)"), + new("AVG", "Child Aggregates", "AVG(Table.Field)", "Averages child-row values for the current parent record.", "=AVG(OrderLines.Amount)", "AVG(Table.Field)"), + new("MIN", "Child Aggregates", "MIN(Table.Field)", "Returns the minimum child-row value.", "=MIN(OrderLines.Amount)", "MIN(Table.Field)"), + new("MAX", "Child Aggregates", "MAX(Table.Field)", "Returns the maximum child-row value.", "=MAX(OrderLines.Amount)", "MAX(Table.Field)"), + ]; + + public static IReadOnlyList AllFunctions { get; } = + ExpressionFunctions.Concat(AggregateFunctions).ToArray(); + + public static IReadOnlyList Categories { get; } = + AllFunctions + .Select(static function => function.Category) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public static IReadOnlyCollection BuiltInFunctionNames { get; } = + ExpressionFunctions.Select(static function => function.Name).ToArray(); +} diff --git a/src/CSharpDB.Admin.Forms/Models/ControlDefinition.cs b/src/CSharpDB.Admin.Forms/Models/ControlDefinition.cs index a1b02b12..cde54234 100644 --- a/src/CSharpDB.Admin.Forms/Models/ControlDefinition.cs +++ b/src/CSharpDB.Admin.Forms/Models/ControlDefinition.cs @@ -7,4 +7,5 @@ public sealed record ControlDefinition( BindingDefinition? Binding, PropertyBag Props, ValidationOverride? ValidationOverride, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null); diff --git a/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs b/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs new file mode 100644 index 00000000..c4f21be1 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs @@ -0,0 +1,18 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Models; + +public enum ControlEventKind +{ + OnClick, + OnChange, + OnGotFocus, + OnLostFocus, +} + +public sealed record ControlEventBinding( + ControlEventKind Event, + string CommandName, + IReadOnlyDictionary? Arguments = null, + bool StopOnFailure = true, + DbActionSequence? ActionSequence = null); diff --git a/src/CSharpDB.Admin.Forms/Models/ControlRuleDefinition.cs b/src/CSharpDB.Admin.Forms/Models/ControlRuleDefinition.cs new file mode 100644 index 00000000..69919c5b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/ControlRuleDefinition.cs @@ -0,0 +1,12 @@ +namespace CSharpDB.Admin.Forms.Models; + +public sealed record ControlRuleDefinition( + string RuleId, + string Condition, + IReadOnlyList Effects, + string? Description = null); + +public sealed record ControlRuleEffect( + string ControlId, + string Property, + object? Value); diff --git a/src/CSharpDB.Admin.Forms/Models/FormAttachmentTableBinding.cs b/src/CSharpDB.Admin.Forms/Models/FormAttachmentTableBinding.cs new file mode 100644 index 00000000..e2bd8111 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormAttachmentTableBinding.cs @@ -0,0 +1,11 @@ +namespace CSharpDB.Admin.Forms.Models; + +public sealed record FormAttachmentTableBinding( + string TableName, + string ForeignKeyField, + string BlobField, + string? FileNameField = null, + string? ContentTypeField = null, + string? FileSizeField = null, + string? ControlIdField = null, + string? ControlId = null); diff --git a/src/CSharpDB.Admin.Forms/Models/FormAttachmentValue.cs b/src/CSharpDB.Admin.Forms/Models/FormAttachmentValue.cs new file mode 100644 index 00000000..f60b6eec --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormAttachmentValue.cs @@ -0,0 +1,18 @@ +namespace CSharpDB.Admin.Forms.Models; + +public sealed record FormAttachmentValue( + byte[]? Bytes, + string? FileName, + string? ContentType, + long? FileSize, + bool ClearExisting) +{ + public static string GetRecordKey(string controlId) + => $"__formAttachment:{controlId}"; + + public static FormAttachmentValue FromFile(byte[] bytes, string fileName, string? contentType, long fileSize) + => new(bytes, fileName, contentType, fileSize, ClearExisting: false); + + public static FormAttachmentValue Clear() + => new(null, null, null, null, ClearExisting: true); +} diff --git a/src/CSharpDB.Admin.Forms/Models/FormControlContexts.cs b/src/CSharpDB.Admin.Forms/Models/FormControlContexts.cs new file mode 100644 index 00000000..d08d511e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormControlContexts.cs @@ -0,0 +1,34 @@ +using CSharpDB.Admin.Forms.Components.Designer; + +namespace CSharpDB.Admin.Forms.Models; + +public sealed record FormControlDesignContext( + ControlDefinition Control, + DesignerState State, + bool IsSelected, + Rect EffectiveRect, + FormControlDescriptor Descriptor); + +public sealed record FormControlRuntimeContext( + FormDefinition Form, + ControlDefinition Control, + FormControlDescriptor Descriptor, + FormTableDefinition? TableDefinition, + Dictionary Record, + string? FieldName, + object? BoundValue, + IReadOnlyList Choices, + bool IsEnabled, + bool IsReadOnly, + string? ValidationError, + int TabIndex, + Func SetValueAsync, + Func?, Task> DispatchEventAsync); + +public sealed record FormControlPropertyContext( + ControlDefinition Control, + FormControlDescriptor Descriptor, + FormTableDefinition? SourceTableDefinition, + IReadOnlyList? AvailableTables, + IReadOnlyList? AvailableForms, + Func SetPropertyAsync); diff --git a/src/CSharpDB.Admin.Forms/Models/FormControlDescriptor.cs b/src/CSharpDB.Admin.Forms/Models/FormControlDescriptor.cs new file mode 100644 index 00000000..c086e004 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormControlDescriptor.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Components; + +namespace CSharpDB.Admin.Forms.Models; + +public sealed record FormControlDescriptor +{ + public required string ControlType { get; init; } + public required string DisplayName { get; init; } + public string ToolboxGroup { get; init; } = "Custom"; + public string IconText { get; init; } = "?"; + public string? Description { get; init; } + public double DefaultWidth { get; init; } = 320; + public double DefaultHeight { get; init; } = 34; + public bool SupportsBinding { get; init; } = true; + public bool ParticipatesInTabOrder { get; init; } = true; + public bool ShowInToolbox { get; init; } = true; + public int ToolboxGroupOrder { get; init; } = 100; + public int ToolboxOrder { get; init; } = 100; + public IReadOnlyDictionary DefaultProps { get; init; } = + new Dictionary(); + public IReadOnlyList PropertyDescriptors { get; init; } = + []; + public Type? DesignerPreviewComponentType { get; init; } + public Type? RuntimeComponentType { get; init; } + public Type? PropertyEditorComponentType { get; init; } + public bool ReplaceBuiltInRuntime { get; init; } + public bool IsBuiltIn { get; init; } + + public Dictionary CreateDefaultProps() + => DefaultProps.ToDictionary(pair => pair.Key, pair => CloneDefaultValue(pair.Value), StringComparer.Ordinal); + + private static object? CloneDefaultValue(object? value) + { + if (value is null) + return null; + + if (value is JsonElement json) + return json.Clone(); + + if (value is object?[] array) + return array.Select(CloneDefaultValue).ToArray(); + + if (value is IReadOnlyDictionary readOnlyDictionary) + return readOnlyDictionary.ToDictionary(pair => pair.Key, pair => CloneDefaultValue(pair.Value), StringComparer.Ordinal); + + if (value is IDictionary dictionary) + return dictionary.ToDictionary(pair => pair.Key, pair => CloneDefaultValue(pair.Value), StringComparer.Ordinal); + + return value; + } + + public static void ValidateComponentType(Type? componentType, string propertyName) + { + if (componentType is not null && !typeof(IComponent).IsAssignableFrom(componentType)) + throw new ArgumentException($"{propertyName} must implement {nameof(IComponent)}.", propertyName); + } +} + +public sealed record FormControlPropertyDescriptor +{ + public required string Name { get; init; } + public required string Label { get; init; } + public FormControlPropertyEditor Editor { get; init; } = FormControlPropertyEditor.Text; + public object? DefaultValue { get; init; } + public string? Placeholder { get; init; } + public string? HelpText { get; init; } + public IReadOnlyList Options { get; init; } = []; +} + +public sealed record FormControlPropertyOption(string Value, string Label); + +public enum FormControlPropertyEditor +{ + Text, + TextArea, + Number, + Checkbox, + Select, +} diff --git a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs index c823c971..7ed0c3f2 100644 --- a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs +++ b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs @@ -1,3 +1,5 @@ +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Forms.Models; public sealed record FormDefinition( @@ -8,4 +10,9 @@ public sealed record FormDefinition( string SourceSchemaSignature, LayoutDefinition Layout, IReadOnlyList Controls, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null, + DbAutomationMetadata? Automation = null, + IReadOnlyList? ActionSequences = null, + IReadOnlyList? Rules = null, + IReadOnlyList? ValidationRules = null); diff --git a/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs b/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs new file mode 100644 index 00000000..754b031c --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs @@ -0,0 +1,22 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Models; + +public enum FormEventKind +{ + OnOpen, + OnLoad, + BeforeInsert, + AfterInsert, + BeforeUpdate, + AfterUpdate, + BeforeDelete, + AfterDelete, +} + +public sealed record FormEventBinding( + FormEventKind Event, + string CommandName, + IReadOnlyDictionary? Arguments = null, + bool StopOnFailure = true, + DbActionSequence? ActionSequence = null); diff --git a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor index a3b12b07..4e4bd379 100644 --- a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor +++ b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor @@ -1,13 +1,23 @@ @using System.Globalization +@using System.Text.Json +@using CSharpDB.Client +@using CSharpDB.Client.Models @using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Primitives @implements IDisposable +@implements IFormActionRuntime @inject IFormRepository FormRepository @inject IFormRecordService RecordService @inject ISchemaProvider SchemaProvider @inject IValidationInferenceService ValidationService +@inject DbFunctionRegistry Functions +@inject DbExtensionPolicy CallbackPolicy @inject IJSRuntime JS -
+
+ @if (ShowToolbar) + {
@if (!string.IsNullOrWhiteSpace(BackHref)) { @@ -60,6 +70,7 @@ }
+ } @if (_error is not null) { @@ -90,11 +101,16 @@ Choices="_choices" ValidationErrors="_validationErrors" ChildFormTableDefinitions="_childTableDefs" + OnCommandError="OnCommandError" + OnBuiltInAction="ExecuteBuiltInFormActionAsync" + ActionRuntime="this" + ControlPropertyOverrides="_controlPropertyOverrides" + ControlFilters="_controlFilters" OnChildRowsChanged="OnChildRowsChanged" />
} - @if (_form is not null) + @if (_form is not null && ShowRecordList) {
OnOpenDesigner { get; set; } [Parameter] public bool ShowDesignerButton { get; set; } = true; + [Parameter] public bool ShowToolbar { get; set; } = true; + [Parameter] public bool ShowRecordList { get; set; } = true; + [Parameter] public bool Embedded { get; set; } [Parameter] public string? BackHref { get; set; } [Parameter] public string BackLabel { get; set; } = "Forms"; + [Parameter] public EventCallback OnOpenForm { get; set; } + [Parameter] public EventCallback OnCloseForm { get; set; } + [Parameter] public bool EnableSqlActions { get; set; } + [Parameter] public bool EnableProcedureActions { get; set; } + [Parameter] public object? InitialRecordId { get; set; } + [Parameter] public string? InitialMode { get; set; } + [Parameter] public string? InitialFilterExpression { get; set; } + [Parameter] public IReadOnlyDictionary? InitialFilterParameters { get; set; } + [Inject] public IFormEventDispatcher FormEvents { get; set; } = NullFormEventDispatcher.Instance; + [Inject] public ICSharpDbClient? DbClient { get; set; } + [Inject] public NavigationManager? Navigation { get; set; } private FormDefinition? _form; private FormTableDefinition? _table; @@ -220,23 +250,33 @@ private string _searchValue = string.Empty; private string? _activeSearchColumnName; private string? _activeSearchValue; + private string? _activeFilterExpression; + private FormFilterExpression? _activeFilter; + private IReadOnlyDictionary? _activeFilterParameters; private readonly Stack> _undoStack = new(); private readonly Stack> _redoStack = new(); private Dictionary? _validationErrors; private readonly Dictionary _childTableDefs = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _formulaDomainTableDefs = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary>> _formulaDomainRows = new(StringComparer.OrdinalIgnoreCase); private readonly List _computedControls = []; private readonly HashSet _computedFieldNames = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _controlPropertyOverrides = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _controlFilters = new(StringComparer.OrdinalIgnoreCase); private string? _loadedFormId; + private string _appliedInitialStateKey = string.Empty; private ElementReference _layoutRef; private ElementReference _recordListRef; private const int RecordPaneMinWidth = 320; private const int RecordPaneMaxWidth = 720; + private const int FormulaDomainRowLimit = 5000; private bool CanOpenDesigner => ShowDesignerButton && OnOpenDesigner.HasDelegate; private int TotalPages => Math.Max(1, (int)Math.Ceiling(_totalRecords / (double)_pageSize)); private int CurrentRecordOrdinal => _isNew || _recordPageIndex < 0 ? 0 : ((_page - 1) * _pageSize) + _recordPageIndex + 1; private bool HasActiveSearch => !string.IsNullOrWhiteSpace(_activeSearchColumnName) && !string.IsNullOrWhiteSpace(_activeSearchValue); + private bool HasActiveFilter => _activeFilter is not null && !string.IsNullOrWhiteSpace(_activeFilterExpression); private bool HasAnySearchInput => HasActiveSearch || !string.IsNullOrWhiteSpace(_searchValue); private bool CanApplySearch => !string.IsNullOrWhiteSpace(_searchColumnName) && !string.IsNullOrWhiteSpace(_searchValue); private bool SupportsPrimaryKeyNavigation => _table?.HasSinglePrimaryKey == true; @@ -250,18 +290,40 @@ ? !_isNew && _recordPageIndex >= 0 && (_recordPageIndex < _records.Count - 1 || _hasFocusedNextRecords) : !_isNew && CurrentRecordOrdinal < _totalRecords; + private string GetDataEntryLayoutClass() + { + List classes = ["data-entry-layout"]; + if (!ShowRecordList) + classes.Add("data-entry-no-record-list"); + if (!ShowToolbar) + classes.Add("data-entry-no-toolbar"); + if (Embedded) + classes.Add("data-entry-embedded"); + + return string.Join(" ", classes); + } + protected override async Task OnParametersSetAsync() { - if (string.IsNullOrWhiteSpace(FormId) || string.Equals(_loadedFormId, FormId, StringComparison.Ordinal)) + if (string.IsNullOrWhiteSpace(FormId)) + return; + + string initialStateKey = BuildInitialStateKey(); + if (!string.Equals(_loadedFormId, FormId, StringComparison.Ordinal)) + { + _loadedFormId = FormId; + _appliedInitialStateKey = string.Empty; + await LoadAsync(); return; + } - _loadedFormId = FormId; - await LoadAsync(); + if (_form is not null && !string.Equals(_appliedInitialStateKey, initialStateKey, StringComparison.Ordinal)) + await ApplyInitialEntryStateAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender) + if (!firstRender || !ShowRecordList) return; try @@ -299,8 +361,13 @@ _error = null; _validationErrors = null; _childTableDefs.Clear(); + _formulaDomainTableDefs.Clear(); + _formulaDomainRows.Clear(); _computedControls.Clear(); _computedFieldNames.Clear(); + _controlPropertyOverrides.Clear(); + _controlFilters.Clear(); + ClearFilterState(); try { @@ -322,9 +389,16 @@ await LoadChildTableDefinitionsAsync(_form); CacheComputedControls(_form); + if (!await DispatchFormEventAsync(FormEventKind.OnOpen)) + return; + InitializeSearchState(); _page = 1; await LoadRecordPageAsync(_page); + if (_error is null) + await ApplyInitialEntryStateAsync(); + if (_error is null) + await DispatchFormEventAsync(FormEventKind.OnLoad, _currentRecord); } catch (Exception ex) { @@ -332,6 +406,94 @@ } } + private async Task ApplyInitialEntryStateAsync() + { + _appliedInitialStateKey = BuildInitialStateKey(); + if (_table is null) + return; + + string? mode = InitialMode?.Trim(); + if (string.Equals(mode, "new", StringComparison.OrdinalIgnoreCase)) + { + if (SupportsWriteOperations) + NewRecord(); + return; + } + + if (!string.IsNullOrWhiteSpace(InitialFilterExpression)) + { + if (!FormFilterExpression.TryParse(InitialFilterExpression, _table, out FormFilterExpression? parsed, out string? filterError)) + { + _error = $"Initial filter is invalid: {filterError}"; + return; + } + + IReadOnlyDictionary parameters = InitialFilterParameters ?? EmptyObjectDictionary.Instance; + string? missingParameter = parsed!.Parameters.FirstOrDefault(parameter => !parameters.ContainsKey(parameter)); + if (missingParameter is not null) + { + _error = $"Initial filter is missing parameter '@{missingParameter}'."; + return; + } + + _activeFilterExpression = InitialFilterExpression; + _activeFilter = parsed; + _activeFilterParameters = parameters; + ClearSearchState(clearPendingValue: true); + await LoadRecordPageAsync(1, preserveCurrentRecordWhenEmpty: _currentRecord.Count > 0 || _isNew); + } + + if (InitialRecordId is not null) + await NavigateToInitialRecordAsync(InitialRecordId); + } + + private async Task NavigateToInitialRecordAsync(object initialRecordId) + { + FormFieldDefinition? primaryKeyField = GetPrimaryKeyField(); + if (primaryKeyField is null) + { + _error = "Initial record navigation requires a form source with a single primary key column."; + return; + } + + string rawValue = NormalizeActionValue(initialRecordId)?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(rawValue)) + return; + + if (!TryParseFieldValue(primaryKeyField, rawValue, out object? parsedValue, out string? validationError) || parsedValue is null) + { + _error = validationError ?? $"'{rawValue}' is not a valid {GetPrimaryKeyLabel()} value."; + return; + } + + ClearSearchState(clearPendingValue: true); + await NavigateToRecordAsync(parsedValue); + } + + private string BuildInitialStateKey() + => string.Join( + "|", + FormId, + InitialMode?.Trim() ?? string.Empty, + FormatInitialStateValue(InitialRecordId), + InitialFilterExpression?.Trim() ?? string.Empty, + FormatInitialParameterKey(InitialFilterParameters)); + + private static string FormatInitialParameterKey(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + return string.Empty; + + return string.Join( + "\u001f", + parameters + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Select(pair => $"{pair.Key}={FormatInitialStateValue(pair.Value)}")); + } + + private static string FormatInitialStateValue(object? value) + => value is null ? "" : $"{value.GetType().FullName}:{value}"; + private async Task>> LoadChoicesAsync(FormDefinition form, FormTableDefinition table) { var choices = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -342,15 +504,18 @@ choices[field.Name] = field.Choices; } - foreach (var control in form.Controls.Where(control => control.ControlType == "lookup")) + foreach (var control in form.Controls.Where(FormChoiceResolver.UsesLookupChoices)) { string? bindingField = control.Binding?.FieldName; - string? lookupTableName = control.Props.Values.TryGetValue("lookupTable", out var lookupTable) ? lookupTable?.ToString() : null; - string? displayField = control.Props.Values.TryGetValue("displayField", out var display) ? display?.ToString() : null; - string? valueField = control.Props.Values.TryGetValue("valueField", out var value) ? value?.ToString() : null; - - if (string.IsNullOrWhiteSpace(bindingField) || - string.IsNullOrWhiteSpace(lookupTableName) || + FormChoiceResolver.TryGetString(control.Props.Values, "lookupTable", out string lookupTableName); + FormChoiceResolver.TryGetString(control.Props.Values, "displayField", out string displayField); + FormChoiceResolver.TryGetString(control.Props.Values, "valueField", out string valueField); + int rowLimit = FormChoiceResolver.ReadInt(control.Props.Values, "rowLimit", 500); + IReadOnlyList displayFields = control.Props.Values.TryGetValue("displayFields", out object? fields) + ? FormChoiceResolver.ReadStringList(fields) + : []; + + if (string.IsNullOrWhiteSpace(lookupTableName) || string.IsNullOrWhiteSpace(displayField) || string.IsNullOrWhiteSpace(valueField)) { @@ -361,13 +526,16 @@ if (lookupTableDef is null) continue; - List> lookupRows = await RecordService.ListRecordsAsync(lookupTableDef); - var enumChoices = lookupRows - .Select(row => new EnumChoice(LookupField(row, valueField), LookupField(row, displayField))) - .Where(choice => !string.IsNullOrWhiteSpace(choice.Value)) - .ToList(); + FormRecordPage lookupPage = await RecordService.ListRecordPageAsync(lookupTableDef, 1, Math.Clamp(rowLimit, 1, 5000)); + IReadOnlyList enumChoices = FormChoiceResolver.BuildLookupChoices( + lookupPage.Records, + valueField, + displayField, + displayFields); - choices[bindingField] = enumChoices; + choices[control.ControlId] = enumChoices; + if (!string.IsNullOrWhiteSpace(bindingField)) + choices[bindingField] = enumChoices; } return choices; @@ -411,6 +579,7 @@ if (!SupportsWriteOperations) return; + _error = null; ExitFocusedNavigation(); _currentRecord = new Dictionary(StringComparer.OrdinalIgnoreCase); _isNew = true; @@ -432,11 +601,24 @@ return; } - var errors = ValidationService.Evaluate(_form, _currentRecord); + var errors = await ValidationService.EvaluateAsync(_form, _currentRecord); if (errors.Count > 0) { - _validationErrors = errors.ToDictionary(error => error.FieldName, error => error.Message, StringComparer.OrdinalIgnoreCase); - _error = $"Please fix {errors.Count} validation error(s) before saving."; + _validationErrors = errors + .Where(error => !string.IsNullOrWhiteSpace(error.FieldName)) + .GroupBy(error => error.FieldName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Message, StringComparer.OrdinalIgnoreCase); + + string[] globalErrors = errors + .Where(error => string.IsNullOrWhiteSpace(error.FieldName)) + .Select(error => error.Message) + .Where(static message => !string.IsNullOrWhiteSpace(message)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(3) + .ToArray(); + _error = globalErrors.Length > 0 + ? string.Join(" ", globalErrors) + : $"Please fix {errors.Count} validation error(s) before saving."; return; } @@ -451,7 +633,11 @@ if (_isNew) { + if (!await DispatchFormEventAsync(FormEventKind.BeforeInsert, recordToSave)) + return; + Dictionary created = await RecordService.CreateRecordAsync(_table, recordToSave); + await PersistAttachmentTableValuesAsync(recordToSave, created); _currentRecord = CloneRecord(created); _isNew = false; if (TryGetPrimaryKeyValue(created, out object? createdPk) && createdPk is not null) @@ -463,11 +649,18 @@ { await LoadRecordPageAsync(_page); } + + await DispatchFormEventAsync(FormEventKind.AfterInsert, created); } else if (TryGetPrimaryKeyValue(_currentRecord, out object? pkValue)) { + if (!await DispatchFormEventAsync(FormEventKind.BeforeUpdate, _currentRecord)) + return; + Dictionary updated = await RecordService.UpdateRecordAsync(_table, pkValue!, recordToSave); + await PersistAttachmentTableValuesAsync(recordToSave, updated); UpdateVisibleCurrentRecord(updated); + await DispatchFormEventAsync(FormEventKind.AfterUpdate, updated); } _dirty = false; @@ -486,6 +679,77 @@ } } + private async Task PersistAttachmentTableValuesAsync( + IReadOnlyDictionary pendingRecord, + IReadOnlyDictionary savedRecord) + { + if (_form is null || _table is null) + return; + + foreach (ControlDefinition control in _form.Controls.Where(IsAttachmentTableControl)) + { + string pendingKey = FormAttachmentValue.GetRecordKey(control.ControlId); + if (!pendingRecord.TryGetValue(pendingKey, out object? pendingValue) || pendingValue is not FormAttachmentValue attachment) + continue; + + FormAttachmentTableBinding binding = BuildAttachmentTableBinding(control); + string parentKeyField = GetControlStringProp(control, "parentKeyField"); + if (string.IsNullOrWhiteSpace(parentKeyField)) + parentKeyField = RecordService.GetPrimaryKeyColumn(_table); + + object? parentValue = ReadRecordValue(savedRecord, parentKeyField) ?? ReadRecordValue(pendingRecord, parentKeyField); + if (parentValue is null || string.IsNullOrWhiteSpace(parentValue.ToString())) + throw new InvalidOperationException($"Attachment control '{control.ControlId}' requires parent key field '{parentKeyField}'."); + + await RecordService.SaveAttachmentAsync(binding, parentValue, attachment); + } + } + + private static bool IsAttachmentTableControl(ControlDefinition control) + => control.ControlType is "attachment" or "image" && + string.Equals(GetControlStringProp(control, "storageMode"), "attachmentTable", StringComparison.OrdinalIgnoreCase); + + private static FormAttachmentTableBinding BuildAttachmentTableBinding(ControlDefinition control) + { + string tableName = GetControlStringProp(control, "attachmentTable"); + string foreignKeyField = GetControlStringProp(control, "attachmentForeignKeyField"); + string blobField = GetControlStringProp(control, "attachmentBlobField"); + + if (string.IsNullOrWhiteSpace(tableName) || + string.IsNullOrWhiteSpace(foreignKeyField) || + string.IsNullOrWhiteSpace(blobField)) + { + throw new InvalidOperationException($"Attachment control '{control.ControlId}' is missing attachment table, foreign key field, or blob field."); + } + + return new FormAttachmentTableBinding( + tableName, + foreignKeyField, + blobField, + NullIfWhiteSpace(GetControlStringProp(control, "attachmentFileNameField")), + NullIfWhiteSpace(GetControlStringProp(control, "attachmentContentTypeField")), + NullIfWhiteSpace(GetControlStringProp(control, "attachmentFileSizeField")), + NullIfWhiteSpace(GetControlStringProp(control, "attachmentControlIdField")), + control.ControlId); + } + + private static string GetControlStringProp(ControlDefinition control, string key) + => FormChoiceResolver.TryGetString(control.Props.Values, key, out string value) + ? value + : string.Empty; + + private static object? ReadRecordValue(IReadOnlyDictionary record, string key) + { + if (record.TryGetValue(key, out object? value)) + return value; + + string? actualKey = record.Keys.FirstOrDefault(candidate => string.Equals(candidate, key, StringComparison.OrdinalIgnoreCase)); + return actualKey is null ? null : record[actualKey]; + } + + private static string? NullIfWhiteSpace(string value) + => string.IsNullOrWhiteSpace(value) ? null : value; + private async Task DeleteRecord() { if (_table is null || !TryGetPrimaryKeyValue(_currentRecord, out object? pkValue)) @@ -501,9 +765,14 @@ try { + var deletedRecord = CloneRecord(_currentRecord); + if (!await DispatchFormEventAsync(FormEventKind.BeforeDelete, deletedRecord)) + return; + int preferredPageIndex = _recordPageIndex; await RecordService.DeleteRecordAsync(_table, pkValue!); await LoadRecordPageAsync(_page, preferredPageIndex: preferredPageIndex); + await DispatchFormEventAsync(FormEventKind.AfterDelete, deletedRecord); } catch (Exception ex) { @@ -566,6 +835,11 @@ _redoStack.Clear(); } + private void OnCommandError(string message) + { + _error = message; + } + private async Task OnFieldChanged(string fieldName) { _dirty = true; @@ -605,6 +879,790 @@ private Task PrintRecord() => JS.InvokeVoidAsync("window.print").AsTask(); + private async Task ExecuteBuiltInFormActionAsync(DbActionStep step, CancellationToken ct) + { + try + { + return await ExecuteBuiltInFormActionCoreAsync(step, ct); + } + finally + { + await RefreshDataEntryStateAsync(); + } + } + + private async Task ExecuteBuiltInFormActionCoreAsync(DbActionStep step, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + _error = null; + + switch (step.Kind) + { + case DbActionKind.NewRecord: + if (!SupportsWriteOperations) + return FormEventDispatchResult.Failure("NewRecord action requires a writable form source."); + + NewRecord(); + return FormEventDispatchResult.Success(); + + case DbActionKind.SaveRecord: + if (!SupportsWriteOperations) + return FormEventDispatchResult.Failure("SaveRecord action requires a writable form source."); + + await SaveRecord(); + return ToActionResult("SaveRecord"); + + case DbActionKind.DeleteRecord: + if (!SupportsWriteOperations) + return FormEventDispatchResult.Failure("DeleteRecord action requires a writable form source."); + + if (!HasPersistedCurrentRecord) + return FormEventDispatchResult.Failure("DeleteRecord action requires a persisted current record."); + + await DeleteRecord(); + return ToActionResult("DeleteRecord"); + + case DbActionKind.RefreshRecords: + await RefreshCurrentRecordsAsync(); + return ToActionResult("RefreshRecords"); + + case DbActionKind.PreviousRecord: + if (!CanGoPreviousRecord) + return FormEventDispatchResult.Failure("PreviousRecord action cannot move before the first record."); + + await PrevRecord(); + return ToActionResult("PreviousRecord"); + + case DbActionKind.NextRecord: + if (!CanGoNextRecord) + return FormEventDispatchResult.Failure("NextRecord action cannot move past the last record."); + + await NextRecord(); + return ToActionResult("NextRecord"); + + case DbActionKind.GoToRecord: + return await ExecuteGoToRecordActionAsync(step); + + default: + return FormEventDispatchResult.Failure($"Unsupported built-in form action '{step.Kind}'."); + } + } + + public Task ExecuteRecordActionAsync( + FormActionRuntimeContext context, + DbActionStep step, + CancellationToken ct) + => ExecuteBuiltInFormActionAsync(step, ct); + + public async Task OpenFormAsync( + FormActionRuntimeContext context, + string formName, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + IReadOnlyDictionary resolvedArguments = ResolveActionArguments(context, arguments); + FormDefinition? targetForm = await ResolveFormAsync(formName); + if (targetForm is null) + return FormEventDispatchResult.Failure($"OpenForm target '{formName}' was not found."); + + if (!OnOpenForm.HasDelegate) + { + if (Navigation is null) + return FormEventDispatchResult.Failure("OpenForm action requires a host open-form handler."); + + Navigation.NavigateTo($"/forms/{Uri.EscapeDataString(targetForm.FormId)}"); + return FormEventDispatchResult.Success(); + } + + string? mode = TryReadArgumentText(resolvedArguments, "mode", "openMode"); + object? recordId = TryReadArgumentValue(resolvedArguments, "recordId", "primaryKey", "id"); + string? filterExpression = TryReadArgumentText(resolvedArguments, "filter", "where"); + await OnOpenForm.InvokeAsync(new FormOpenRequest(targetForm.FormId, targetForm.Name, resolvedArguments, mode, recordId, filterExpression)); + return FormEventDispatchResult.Success(); + } + + public async Task CloseFormAsync( + FormActionRuntimeContext context, + string? formName, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!OnCloseForm.HasDelegate) + return FormEventDispatchResult.Failure("CloseForm action requires a host close-form handler."); + + await OnCloseForm.InvokeAsync(new FormCloseRequest(_form?.FormId, formName ?? _form?.Name)); + return FormEventDispatchResult.Success(); + } + + public async Task ApplyFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (_table is null) + return FormEventDispatchResult.Failure("ApplyFilter action requires a loaded form source."); + + if (!IsCurrentFormTarget(target)) + return await ApplyControlFilterAsync(context, target, filter, arguments, ct); + + if (!FormFilterExpression.TryParse(filter, _table, out FormFilterExpression? parsed, out string? filterError)) + return FormEventDispatchResult.Failure($"ApplyFilter action has an invalid filter expression: {filterError}"); + + IReadOnlyDictionary resolvedArguments = BuildActionParameterDictionary(context, arguments); + string? missingParameter = parsed!.Parameters.FirstOrDefault(parameter => !resolvedArguments.ContainsKey(parameter)); + if (missingParameter is not null) + return FormEventDispatchResult.Failure($"ApplyFilter action is missing parameter '@{missingParameter}'."); + + _activeFilterExpression = filter; + _activeFilter = parsed; + _activeFilterParameters = resolvedArguments; + ClearSearchState(clearPendingValue: true); + await LoadRecordPageAsync(1, preserveCurrentRecordWhenEmpty: _currentRecord.Count > 0 || _isNew); + await RefreshDataEntryStateAsync(); + return ToActionResult("ApplyFilter"); + } + + public async Task ClearFilterAsync( + FormActionRuntimeContext context, + string target, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (_table is null) + return FormEventDispatchResult.Failure("ClearFilter action requires a loaded form source."); + + if (!IsCurrentFormTarget(target)) + return await ClearControlFilterAsync(target, ct); + + object? currentPk = TryGetPrimaryKeyValue(_currentRecord, out object? pkValue) ? pkValue : null; + ClearFilterState(); + await LoadRecordPageAsync(1, preferredPk: currentPk, preserveCurrentRecordWhenEmpty: _currentRecord.Count > 0 || _isNew); + await RefreshDataEntryStateAsync(); + return ToActionResult("ClearFilter"); + } + + public async Task RunSqlAsync( + FormActionRuntimeContext context, + string sqlOrName, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!EnableSqlActions) + return FormEventDispatchResult.Failure("RunSql action is disabled by host policy."); + if (DbClient is null) + return FormEventDispatchResult.Failure("RunSql action requires a database client."); + + IReadOnlyDictionary resolvedArguments = BuildActionParameterDictionary(context, arguments); + string? sql = await ResolveSqlTextAsync(sqlOrName, ct); + if (string.IsNullOrWhiteSpace(sql)) + return FormEventDispatchResult.Failure($"RunSql action could not resolve SQL operation '{sqlOrName}'."); + + if (!TrySubstituteSqlParameters(sql, resolvedArguments, out string resolvedSql, out string? parameterError)) + return FormEventDispatchResult.Failure(parameterError ?? "RunSql action has invalid SQL parameters."); + + SqlExecutionResult result = await DbClient.ExecuteSqlAsync(resolvedSql, ct); + if (!string.IsNullOrWhiteSpace(result.Error)) + return FormEventDispatchResult.Failure($"RunSql action failed: {result.Error}"); + + if (TryReadArgumentText(resolvedArguments, "setField", "targetField") is { } targetField) + await SetRuntimeFieldValueAsync(targetField, ReadScalarResult(result)); + + if (ShouldRefreshAfterAction(resolvedArguments, defaultValue: true)) + await RefreshCurrentRecordsAsync(); + + await RefreshDataEntryStateAsync(); + string message = result.IsQuery + ? $"RunSql action returned {result.Rows?.Count ?? 0} row(s)." + : $"RunSql action affected {result.RowsAffected} row(s)."; + FormEventDispatchResult actionResult = ToActionResult("RunSql"); + return actionResult.Succeeded ? FormEventDispatchResult.Success(message) : actionResult; + } + + public async Task RunProcedureAsync( + FormActionRuntimeContext context, + string procedureName, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!EnableProcedureActions) + return FormEventDispatchResult.Failure("RunProcedure action is disabled by host policy."); + if (DbClient is null) + return FormEventDispatchResult.Failure("RunProcedure action requires a database client."); + + IReadOnlyDictionary resolvedArguments = BuildExecutableArguments(BuildActionParameterDictionary(context, arguments)); + ProcedureExecutionResult result = await DbClient.ExecuteProcedureAsync(procedureName, resolvedArguments, ct); + if (!result.Succeeded) + return FormEventDispatchResult.Failure($"RunProcedure action failed: {result.Error ?? $"Procedure '{procedureName}' failed."}"); + + if (ShouldRefreshAfterAction(arguments, defaultValue: true)) + await RefreshCurrentRecordsAsync(); + + await RefreshDataEntryStateAsync(); + FormEventDispatchResult actionResult = ToActionResult("RunProcedure"); + return actionResult.Succeeded + ? FormEventDispatchResult.Success($"RunProcedure action executed '{procedureName}'.") + : actionResult; + } + + public async Task SetControlPropertyAsync( + FormActionRuntimeContext context, + string controlId, + string propertyName, + object? value, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (_form is null) + return FormEventDispatchResult.Failure("SetControlProperty action requires a loaded form."); + + ControlDefinition? control = _form.Controls.FirstOrDefault(candidate => string.Equals(candidate.ControlId, controlId, StringComparison.OrdinalIgnoreCase)); + if (control is null) + return FormEventDispatchResult.Failure($"SetControlProperty action targets unknown control '{controlId}'."); + + string normalizedProperty = NormalizeControlPropertyName(propertyName); + object? resolvedValue = ResolveActionValue(value, context); + if (string.Equals(normalizedProperty, "value", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(control.Binding?.FieldName)) + return FormEventDispatchResult.Failure($"SetControlProperty action cannot set value for unbound control '{controlId}'."); + + await SetRuntimeFieldValueAsync(control.Binding.FieldName, resolvedValue); + await RefreshDataEntryStateAsync(); + return FormEventDispatchResult.Success(); + } + + if (!IsSupportedRuntimeControlProperty(normalizedProperty)) + return FormEventDispatchResult.Failure($"SetControlProperty action does not support property '{propertyName}'."); + + if (!_controlPropertyOverrides.TryGetValue(control.ControlId, out IReadOnlyDictionary? existing) || + existing is not Dictionary overrides) + { + overrides = existing?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + _controlPropertyOverrides[control.ControlId] = overrides; + } + + overrides[normalizedProperty] = NormalizeActionValue(resolvedValue); + await RefreshDataEntryStateAsync(); + return FormEventDispatchResult.Success(); + } + + private async Task RefreshDataEntryStateAsync() + { + try + { + await InvokeAsync(StateHasChanged); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("render handle", StringComparison.OrdinalIgnoreCase)) + { + // Private unit-test invocations can run before Blazor assigns a render handle. + } + } + + private FormEventDispatchResult ToActionResult(string actionName) + => string.IsNullOrWhiteSpace(_error) + ? FormEventDispatchResult.Success() + : FormEventDispatchResult.Failure($"{actionName} action failed: {_error}"); + + private async Task ResolveFormAsync(string formName) + { + if (string.IsNullOrWhiteSpace(formName)) + return null; + + string trimmed = formName.Trim(); + FormDefinition? direct = await FormRepository.GetAsync(trimmed); + if (direct is not null) + return direct; + + try + { + IReadOnlyList forms = await FormRepository.ListAsync(); + return forms.FirstOrDefault(form => + string.Equals(form.FormId, trimmed, StringComparison.OrdinalIgnoreCase) || + string.Equals(form.Name, trimmed, StringComparison.OrdinalIgnoreCase)); + } + catch (NotSupportedException) + { + return null; + } + } + + private bool IsCurrentFormTarget(string? target) + => string.IsNullOrWhiteSpace(target) || + string.Equals(target, "form", StringComparison.OrdinalIgnoreCase) || + string.Equals(target, _form?.FormId, StringComparison.OrdinalIgnoreCase) || + string.Equals(target, _form?.Name, StringComparison.OrdinalIgnoreCase); + + private async Task ApplyControlFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (!TryResolveDataGridFilterTarget(target, out ControlDefinition? control, out FormTableDefinition? table, out string? targetError)) + return FormEventDispatchResult.Failure(targetError ?? $"ApplyFilter target '{target}' is not supported by this rendered form runtime."); + + if (!FormFilterExpression.TryParse(filter, table, out FormFilterExpression? parsed, out string? filterError)) + return FormEventDispatchResult.Failure($"ApplyFilter action has an invalid filter expression for control '{control!.ControlId}': {filterError}"); + + IReadOnlyDictionary resolvedArguments = BuildActionParameterDictionary(context, arguments); + string? missingParameter = parsed!.Parameters.FirstOrDefault(parameter => !resolvedArguments.ContainsKey(parameter)); + if (missingParameter is not null) + return FormEventDispatchResult.Failure($"ApplyFilter action is missing parameter '@{missingParameter}'."); + + _controlFilters[control!.ControlId] = new ControlFilterState(filter, resolvedArguments); + await RefreshDataEntryStateAsync(); + return FormEventDispatchResult.Success(); + } + + private async Task ClearControlFilterAsync(string target, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (!TryResolveDataGridFilterTarget(target, out ControlDefinition? control, out _, out string? targetError)) + return FormEventDispatchResult.Failure(targetError ?? $"ClearFilter target '{target}' is not supported by this rendered form runtime."); + + _controlFilters.Remove(control!.ControlId); + await RefreshDataEntryStateAsync(); + return FormEventDispatchResult.Success(); + } + + private bool TryResolveDataGridFilterTarget( + string target, + out ControlDefinition? control, + out FormTableDefinition? table, + out string? error) + { + control = null; + table = null; + error = null; + + if (_form is null) + { + error = "Control filter actions require a loaded form."; + return false; + } + + control = _form.Controls.FirstOrDefault(candidate => string.Equals(candidate.ControlId, target, StringComparison.OrdinalIgnoreCase)); + if (control is null) + { + error = $"Filter target '{target}' does not match the current form or a known control."; + return false; + } + + if (!string.Equals(control.ControlType, "datagrid", StringComparison.OrdinalIgnoreCase)) + { + error = $"Filter target '{target}' is not a DataGrid control."; + return false; + } + + string? childTableName = control.Props.Values.TryGetValue("childTable", out object? childTableValue) + ? childTableValue?.ToString() + : null; + if (string.IsNullOrWhiteSpace(childTableName)) + { + error = $"DataGrid control '{target}' does not have a child table configured."; + return false; + } + + if (!_childTableDefs.TryGetValue(childTableName, out table)) + { + error = $"DataGrid control '{target}' child table '{childTableName}' was not found."; + return false; + } + + return true; + } + + private IReadOnlyDictionary BuildActionParameterDictionary( + FormActionRuntimeContext context, + IReadOnlyDictionary arguments) + { + Dictionary resolved = ResolveActionArguments(context, arguments).ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.OrdinalIgnoreCase); + + if (resolved.TryGetValue("parameters", out object? nestedParameters)) + { + foreach ((string key, object? value) in ToObjectDictionary(nestedParameters)) + resolved[key] = ResolveActionValue(value, context); + } + + return resolved; + } + + private IReadOnlyDictionary ResolveActionArguments( + FormActionRuntimeContext context, + IReadOnlyDictionary arguments) + { + if (arguments.Count == 0) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((string key, object? value) in arguments) + { + if (!string.IsNullOrWhiteSpace(key)) + resolved[key] = ResolveActionValue(value, context); + } + + return resolved; + } + + private object? ResolveActionValue(object? value, FormActionRuntimeContext context) + { + value = NormalizeActionValue(value); + if (value is string text) + { + if (TryResolvePath(text, "$record.", context.Record ?? _currentRecord, out object? recordValue)) + return recordValue; + if (TryResolvePath(text, "$binding.", context.BindingArguments, out object? bindingValue)) + return bindingValue; + if (TryResolvePath(text, "$runtime.", context.RuntimeArguments, out object? runtimeValue)) + return runtimeValue; + if (TryResolvePath(text, "$arguments.", context.StepArguments, out object? argumentValue)) + return argumentValue; + } + + return value; + } + + private static bool TryResolvePath( + string value, + string prefix, + IReadOnlyDictionary? source, + out object? resolved) + { + resolved = null; + if (source is null || !value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + + string key = value[prefix.Length..].Trim(); + if (key.Length == 0) + return false; + + if (source.TryGetValue(key, out resolved)) + return true; + + string? actualKey = source.Keys.FirstOrDefault(candidate => string.Equals(candidate, key, StringComparison.OrdinalIgnoreCase)); + if (actualKey is null) + return false; + + resolved = source[actualKey]; + return true; + } + + private static object? NormalizeActionValue(object? value) + { + return value switch + { + JsonElement json => NormalizeJsonActionValue(json), + _ => value, + }; + } + + private static object? NormalizeJsonActionValue(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary( + property => property.Name, + property => NormalizeJsonActionValue(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => value.EnumerateArray().Select(NormalizeJsonActionValue).ToArray(), + _ => value.ToString(), + }; + } + + private static IReadOnlyDictionary ToObjectDictionary(object? value) + { + value = NormalizeActionValue(value); + if (value is IReadOnlyDictionary readOnly) + return readOnly; + + if (value is IDictionary dictionary) + return dictionary.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static IReadOnlyDictionary BuildExecutableArguments(IReadOnlyDictionary arguments) + { + var executable = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((string key, object? value) in arguments) + { + if (key is "mode" or "refresh" or "procedure" or "procedureName" or "sql" or "name" or "setField" or "targetField") + continue; + + executable[key] = value; + } + + return executable; + } + + private async Task ResolveSqlTextAsync(string sqlOrName, CancellationToken ct) + { + string trimmed = sqlOrName.Trim(); + if (LooksLikeSql(trimmed)) + return trimmed; + + SavedQueryDefinition? savedQuery = await DbClient!.GetSavedQueryAsync(trimmed, ct); + return savedQuery?.SqlText; + } + + private static bool LooksLikeSql(string value) + { + string firstWord = value.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; + return firstWord is "SELECT" or "WITH" or "INSERT" or "UPDATE" or "DELETE" or "CREATE" or "DROP" or "ALTER" or "REPLACE" + || firstWord.Equals("SELECT", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("WITH", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("INSERT", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("UPDATE", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("DELETE", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("CREATE", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("DROP", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("ALTER", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("REPLACE", StringComparison.OrdinalIgnoreCase); + } + + private static bool TrySubstituteSqlParameters( + string sql, + IReadOnlyDictionary arguments, + out string resolvedSql, + out string? error) + { + var builder = new System.Text.StringBuilder(sql.Length); + bool inString = false; + for (int i = 0; i < sql.Length;) + { + char ch = sql[i]; + if (ch == '\'') + { + builder.Append(ch); + if (inString && i + 1 < sql.Length && sql[i + 1] == '\'') + { + builder.Append(sql[i + 1]); + i += 2; + continue; + } + + inString = !inString; + i++; + continue; + } + + if (!inString && ch == '@') + { + int start = i + 1; + int end = start; + while (end < sql.Length && (char.IsLetterOrDigit(sql[end]) || sql[end] == '_')) + end++; + + if (end == start) + { + builder.Append(ch); + i++; + continue; + } + + string parameterName = sql[start..end]; + if (!arguments.TryGetValue(parameterName, out object? value)) + { + resolvedSql = string.Empty; + error = $"RunSql action is missing parameter '@{parameterName}'."; + return false; + } + + builder.Append(FormSql.FormatLiteral(value)); + i = end; + continue; + } + + builder.Append(ch); + i++; + } + + resolvedSql = builder.ToString(); + error = null; + return true; + } + + private static object? ReadScalarResult(SqlExecutionResult result) + => result.Rows is { Count: > 0 } rows && rows[0].Length > 0 + ? rows[0][0] + : null; + + private static bool ShouldRefreshAfterAction(IReadOnlyDictionary arguments, bool defaultValue) + => arguments.TryGetValue("refresh", out object? value) + ? ReadBooleanValue(value, defaultValue) + : defaultValue; + + private static bool ReadBooleanValue(object? value, bool fallback) + { + value = NormalizeActionValue(value); + return value switch + { + bool boolean => boolean, + long integer => integer != 0, + int integer => integer != 0, + string text when bool.TryParse(text, out bool parsed) => parsed, + string text when long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed) => parsed != 0, + _ => fallback, + }; + } + + private static string? TryReadArgumentText( + IReadOnlyDictionary arguments, + params string[] keys) + { + foreach (string key in keys) + { + if (arguments.TryGetValue(key, out object? value)) + { + string? text = NormalizeActionValue(value)?.ToString()?.Trim(); + if (!string.IsNullOrWhiteSpace(text)) + return text; + } + } + + return null; + } + + private static object? TryReadArgumentValue( + IReadOnlyDictionary arguments, + params string[] keys) + { + foreach (string key in keys) + { + if (arguments.TryGetValue(key, out object? value)) + return NormalizeActionValue(value); + } + + return null; + } + + private async Task SetRuntimeFieldValueAsync(string fieldName, object? value) + { + string key = _currentRecord.Keys.FirstOrDefault(candidate => string.Equals(candidate, fieldName, StringComparison.OrdinalIgnoreCase)) + ?? fieldName; + + _undoStack.Push(CloneRecord(_currentRecord)); + _redoStack.Clear(); + _currentRecord[key] = NormalizeActionValue(value); + _dirty = true; + + if (_recordPageIndex >= 0 && _recordPageIndex < _records.Count) + _records[_recordPageIndex] = CloneRecord(_currentRecord); + + _validationErrors?.Remove(key); + if (_computedControls.Count > 0) + await EvaluateComputedFields(); + } + + private static string NormalizeControlPropertyName(string propertyName) + { + string trimmed = propertyName.Trim(); + return trimmed.Equals("readonly", StringComparison.OrdinalIgnoreCase) + ? "readOnly" + : trimmed; + } + + private static bool IsSupportedRuntimeControlProperty(string propertyName) + => propertyName is "visible" or "enabled" or "readOnly" or "required" or "styleVariant" or "validationMessage" or "text" or "placeholder"; + + private async Task ExecuteGoToRecordActionAsync(DbActionStep step) + { + if (_table is null) + return FormEventDispatchResult.Failure("GoToRecord action requires a loaded form source."); + + FormFieldDefinition? primaryKeyField = GetPrimaryKeyField(); + if (primaryKeyField is null) + return FormEventDispatchResult.Failure("GoToRecord action requires a form source with a single primary key column."); + + object? requestedValue = ReadActionValue(step); + if (requestedValue is null && !string.IsNullOrWhiteSpace(step.Target)) + TryGetFieldValue(_currentRecord, step.Target, out requestedValue); + + if (requestedValue is null) + return FormEventDispatchResult.Failure("GoToRecord action requires a record value."); + + string rawValue = requestedValue.ToString() ?? string.Empty; + if (!TryParseFieldValue(primaryKeyField, rawValue, out object? parsedValue, out string? validationError)) + return FormEventDispatchResult.Failure(validationError ?? $"'{rawValue}' is not a valid {GetPrimaryKeyLabel()} value."); + + if (parsedValue is null) + return FormEventDispatchResult.Failure($"'{rawValue}' is not a valid {GetPrimaryKeyLabel()} value."); + + ClearSearchState(clearPendingValue: true); + await NavigateToRecordAsync(parsedValue); + return ToActionResult("GoToRecord"); + } + + private async Task RefreshCurrentRecordsAsync() + { + if (_table is null) + return; + + object? currentPk = TryGetPrimaryKeyValue(_currentRecord, out object? pkValue) ? pkValue : null; + if (currentPk is not null) + { + await NavigateToRecordAsync(currentPk); + return; + } + + await LoadRecordPageAsync(_page, preserveCurrentRecordWhenEmpty: _currentRecord.Count > 0 || _isNew); + } + + private static object? ReadActionValue(DbActionStep step) + { + if (step.Value is not null) + return step.Value; + + if (step.Arguments is null) + return null; + + if (step.Arguments.TryGetValue("value", out object? value)) + return value; + + if (step.Arguments.TryGetValue("recordId", out object? recordId)) + return recordId; + + return step.Arguments.TryGetValue("primaryKey", out object? primaryKey) ? primaryKey : null; + } + + private async Task DispatchFormEventAsync( + FormEventKind eventKind, + IReadOnlyDictionary? record = null) + { + if (_form is null) + return true; + + FormEventDispatchResult result = FormEvents is DefaultFormEventDispatcher defaultDispatcher + ? await defaultDispatcher.DispatchAsync(_form, eventKind, record, this) + : await FormEvents.DispatchAsync(_form, eventKind, record); + if (result.Succeeded) + return true; + + _error = string.IsNullOrWhiteSpace(result.Message) + ? $"Form event '{eventKind}' failed." + : result.Message; + return false; + } + private void OnGoToRecordInput(ChangeEventArgs e) => _goToRecordValue = e.Value?.ToString() ?? string.Empty; private void OnSearchColumnChanged(ChangeEventArgs e) => _searchColumnName = e.Value?.ToString() ?? string.Empty; @@ -657,6 +1715,7 @@ _activeSearchColumnName = _searchColumnName; _activeSearchValue = requestedValue; + ClearFilterState(); _searchValue = requestedValue; _page = 1; _error = null; @@ -704,9 +1763,11 @@ try { - FormRecordPage page = HasActiveSearch - ? await RecordService.SearchRecordPageAsync(_table, _activeSearchColumnName!, _activeSearchValue!, pageNumber, _pageSize) - : await RecordService.ListRecordPageAsync(_table, pageNumber, _pageSize); + FormRecordPage page = HasActiveFilter + ? await LoadFilteredRecordPageAsync(pageNumber) + : HasActiveSearch + ? await RecordService.SearchRecordPageAsync(_table, _activeSearchColumnName!, _activeSearchValue!, pageNumber, _pageSize) + : await RecordService.ListRecordPageAsync(_table, pageNumber, _pageSize); _records = page.Records.Select(CloneRecord).ToList(); _page = page.PageNumber; _pageSize = page.PageSize; @@ -773,6 +1834,34 @@ return 0; } + private async Task LoadFilteredRecordPageAsync(int pageNumber) + { + if (_table is null || _activeFilter is null) + return new FormRecordPage(1, _pageSize, 0, []); + + List> filtered = await GetFilteredRecordsAsync(); + int totalPages = filtered.Count == 0 ? 1 : (int)Math.Ceiling(filtered.Count / (double)_pageSize); + int effectivePage = Math.Clamp(pageNumber, 1, totalPages); + List> pageRecords = filtered + .Skip((effectivePage - 1) * _pageSize) + .Take(_pageSize) + .ToList(); + + return new FormRecordPage(effectivePage, _pageSize, filtered.Count, pageRecords); + } + + private async Task>> GetFilteredRecordsAsync() + { + if (_table is null || _activeFilter is null) + return []; + + List> rows = await RecordService.ListRecordsAsync(_table); + return rows + .Where(row => _activeFilter.Evaluate(row, _activeFilterParameters)) + .Select(CloneRecord) + .ToList(); + } + private async Task LoadFocusedRecordWindowAsync(object pkValue) { if (_table is null) @@ -862,9 +1951,17 @@ if (_table is null) return; - int? ordinal = HasActiveSearch - ? await RecordService.GetRecordOrdinalAsync(_table, pkValue, _activeSearchColumnName!, _activeSearchValue!) - : await RecordService.GetRecordOrdinalAsync(_table, pkValue); + int? ordinal = HasActiveFilter + ? await GetFilteredRecordOrdinalAsync(pkValue) + : HasActiveSearch + ? await RecordService.GetRecordOrdinalAsync(_table, pkValue, _activeSearchColumnName!, _activeSearchValue!) + : await RecordService.GetRecordOrdinalAsync(_table, pkValue); + + if (ordinal is null && HasActiveFilter) + { + ClearFilterState(); + ordinal = await RecordService.GetRecordOrdinalAsync(_table, pkValue); + } if (ordinal is null && HasActiveSearch) { @@ -882,6 +1979,22 @@ await LoadRecordPageAsync(targetPage, preferredPk: pkValue); } + private async Task GetFilteredRecordOrdinalAsync(object pkValue) + { + if (_table is null || !_table.HasSinglePrimaryKey) + return null; + + string pkColumn = RecordService.GetPrimaryKeyColumn(_table); + List> filtered = await GetFilteredRecordsAsync(); + for (int i = 0; i < filtered.Count; i++) + { + if (TryGetFieldValue(filtered[i], pkColumn, out object? candidatePk) && AreValuesEqual(candidatePk, pkValue)) + return i; + } + + return null; + } + private void UpdateVisibleCurrentRecord(Dictionary updated) { var clonedRecord = CloneRecord(updated); @@ -1017,20 +2130,193 @@ } else { - _currentRecord[fieldName] = FormulaEvaluator.Evaluate(formula, field => + await LoadFormulaDomainReferencesAsync(formula); + _currentRecord[fieldName] = FormulaEvaluator.EvaluateValue(formula, field => + { + return TryGetFieldValue(_currentRecord, field, out object? value) + ? value + : null; + }, Functions, CallbackPolicy, ResolveFormulaDomainFunction); + } + } + } + + private async Task LoadFormulaDomainReferencesAsync(string formula) + { + foreach (string domain in FormulaEvaluator.GetDomainReferences(formula)) + { + if (_formulaDomainRows.ContainsKey(domain)) + continue; + + FormTableDefinition? domainTable = await SchemaProvider.GetTableDefinitionAsync(domain); + if (domainTable is null) + continue; + + FormRecordPage page = await RecordService.ListRecordPageAsync(domainTable, 1, FormulaDomainRowLimit); + _formulaDomainTableDefs[domain] = domainTable; + _formulaDomainRows[domain] = page.Records.Select(CloneRecord).ToList(); + } + } + + private object? ResolveFormulaDomainFunction(FormulaDomainFunctionRequest request) + { + if (!_formulaDomainTableDefs.TryGetValue(request.Domain, out FormTableDefinition? table) || + !_formulaDomainRows.TryGetValue(request.Domain, out List>? rows)) + { + return null; + } + + IReadOnlyList> filteredRows = FilterDomainRows(table, rows, request.Criteria); + string function = request.FunctionName.ToUpperInvariant(); + if (function == "DCOUNT") + { + if (string.Equals(request.Expression.Trim(), "*", StringComparison.Ordinal)) + return filteredRows.Count; + + return filteredRows.Count(row => ReadDomainExpressionValue(request.Expression, row) is not null); + } + + if (function == "DLOOKUP") + return filteredRows.Select(row => ReadDomainExpressionValue(request.Expression, row)).FirstOrDefault(value => value is not null); + + IEnumerable values = filteredRows.Select(row => TryConvertDomainNumber(ReadDomainExpressionValue(request.Expression, row), out double value) + ? value + : (double?)null); + + return function switch + { + "DSUM" => FormulaEvaluator.EvaluateAggregate("SUM", values), + "DAVG" => FormulaEvaluator.EvaluateAggregate("AVG", values), + "DMIN" => FormulaEvaluator.EvaluateAggregate("MIN", values), + "DMAX" => FormulaEvaluator.EvaluateAggregate("MAX", values), + _ => null, + }; + } + + private static IReadOnlyList> FilterDomainRows( + FormTableDefinition table, + IReadOnlyList> rows, + string? criteria) + { + if (string.IsNullOrWhiteSpace(criteria)) + return rows; + + string normalizedCriteria = NormalizeDomainCriteria(criteria, table); + return FormFilterExpression.TryParse(normalizedCriteria, table, out FormFilterExpression? filter, out _) + ? rows.Where(row => filter!.Evaluate(row)).ToArray() + : []; + } + + private static object? ReadDomainExpressionValue(string expression, IReadOnlyDictionary row) + { + string fieldName = NormalizeDomainFieldReference(expression); + if (TryGetFieldValue(row, fieldName, out object? value)) + return value; + + return FormulaEvaluator.EvaluateValue( + expression.StartsWith('=') ? expression : $"={expression}", + field => TryGetFieldValue(row, field, out object? fieldValue) ? fieldValue : null); + } + + private static string NormalizeDomainFieldReference(string expression) + { + string trimmed = expression.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']') && trimmed.Length > 2) + return trimmed[1..^1].Trim(); + + return trimmed; + } + + private static string NormalizeDomainCriteria(string criteria, FormTableDefinition table) + { + var fieldNames = new HashSet(table.Fields.Select(static field => field.Name), StringComparer.OrdinalIgnoreCase); + var builder = new System.Text.StringBuilder(criteria.Length + 16); + for (int i = 0; i < criteria.Length;) + { + char ch = criteria[i]; + if (ch is '\'' or '"') + { + int start = i; + i++; + while (i < criteria.Length) { - if (TryGetFieldValue(_currentRecord, field, out object? value) && value is not null) + if (criteria[i] == ch) { - if (value is double d) return d; - if (value is long l) return l; - if (value is int i) return i; - if (double.TryParse(value.ToString(), out var parsed)) return parsed; + i++; + if (i < criteria.Length && criteria[i] == ch) + { + i++; + continue; + } + + break; } - return null; - }); + i++; + } + + builder.Append(criteria, start, i - start); + continue; + } + + if (ch == '[') + { + int start = i; + int end = criteria.IndexOf(']', i + 1); + if (end < 0) + { + builder.Append(criteria[i..]); + break; + } + + builder.Append(criteria, start, end - start + 1); + i = end + 1; + continue; + } + + if (char.IsLetter(ch) || ch == '_') + { + int start = i; + i++; + while (i < criteria.Length && (char.IsLetterOrDigit(criteria[i]) || criteria[i] == '_')) + i++; + + string identifier = criteria[start..i]; + if (fieldNames.Contains(identifier)) + builder.Append('[').Append(identifier).Append(']'); + else + builder.Append(identifier); + continue; } + + builder.Append(ch); + i++; } + + return builder.ToString(); + } + + private static bool TryConvertDomainNumber(object? value, out double result) + { + value = FormSql.NormalizeValue(value); + return value switch + { + byte number => Set(number, out result), + short number => Set(number, out result), + int number => Set(number, out result), + long number => Set(number, out result), + float number => Set(number, out result), + double number => Set(number, out result), + decimal number => Set((double)number, out result), + string text => double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out result), + _ => Set(0, out result, success: false), + }; + } + + private static bool Set(double value, out double result, bool success = true) + { + result = value; + return success; } private (string? ForeignKeyField, string? ParentKeyField) FindChildMapping(string childTableName) @@ -1119,6 +2405,13 @@ _searchColumnName = GetDefaultSearchFieldName() ?? string.Empty; } + private void ClearFilterState() + { + _activeFilterExpression = null; + _activeFilter = null; + _activeFilterParameters = null; + } + private IReadOnlyList GetSearchableFields() { if (_table is null) @@ -1308,4 +2601,10 @@ => TryGetFieldValue(record, fieldName, out object? value) && value is not null ? value.ToString() ?? string.Empty : string.Empty; + + private static class EmptyObjectDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/CSharpDB.Admin.Forms/Pages/Designer.razor b/src/CSharpDB.Admin.Forms/Pages/Designer.razor index ad904f8e..9615694a 100644 --- a/src/CSharpDB.Admin.Forms/Pages/Designer.razor +++ b/src/CSharpDB.Admin.Forms/Pages/Designer.razor @@ -43,6 +43,15 @@
+
+ + +
+
} + else + { + + }
@@ -117,6 +130,7 @@ private bool _saving; private string? _error; private string? _schemaWarning; + private FormTableDefinition? _currentSourceDefinition; private bool _saved; private System.Timers.Timer? _savedTimer; private bool _editingName; @@ -217,6 +231,7 @@ } var generated = FormGenerator.GenerateDefault(source) with { FormId = string.Empty }; + _currentSourceDefinition = source; _state.LoadForm(generated); } catch (Exception ex) @@ -228,6 +243,7 @@ private void LoadBlankDesigner(FormTableDefinition? source) { + _currentSourceDefinition = source; _state.LoadForm(new FormDefinition( string.Empty, source is null ? "Untitled Form" : $"{source.TableName} Form", @@ -241,9 +257,12 @@ private async Task OnTableChanged(ChangeEventArgs e) { string? tableName = e.Value?.ToString(); + string previousTableName = _state.TableName; + bool shouldUseDefaultName = ShouldUseDefaultFormName(_state.FormName, previousTableName); if (string.IsNullOrWhiteSpace(tableName)) { _state.SetTableContext(null); + _currentSourceDefinition = null; _schemaWarning = null; return; } @@ -258,11 +277,10 @@ } _state.SetTableContext(source); - if (string.IsNullOrWhiteSpace(_state.FormId) && - (_state.FormName == "Untitled Form" || _state.Controls.Count == 0)) + _currentSourceDefinition = source; + if (string.IsNullOrWhiteSpace(_state.FormId) && shouldUseDefaultName) { - _state.FormName = $"{source.TableName} Form"; - _state.NotifyChanged(); + _state.SetFormName($"{source.TableName} Form"); } _schemaWarning = null; @@ -328,6 +346,7 @@ } FormTableDefinition? current = await SchemaProvider.GetTableDefinitionAsync(form.TableName); + _currentSourceDefinition = current; _schemaWarning = current is not null && !string.Equals(current.SourceSchemaSignature, form.SourceSchemaSignature, StringComparison.Ordinal) ? "The source schema has changed since this form was last saved." : null; @@ -361,6 +380,16 @@ private const string BpMobile = "mobile"; private const string BpTablet = "tablet"; private const string BpDesktop = "desktop"; + private const string LayoutModeAbsolute = "absolute"; + private const string LayoutModeElastic = "elastic"; + + private bool IsLayoutMode(string layoutMode) + => string.Equals(_state.Layout.LayoutMode, layoutMode, StringComparison.OrdinalIgnoreCase); + + private void SetLayoutMode(string layoutMode) + { + _state.SetLayoutMode(layoutMode); + } private void SetBreakpoint(string breakpoint) { @@ -380,11 +409,15 @@ private void CommitNameEdit() { _editingName = false; - string? trimmed = _editNameValue?.Trim(); - if (!string.IsNullOrEmpty(trimmed)) - _state.FormName = trimmed; + _state.SetFormName(_editNameValue); } + private static bool ShouldUseDefaultFormName(string formName, string? previousTableName) + => string.IsNullOrWhiteSpace(formName) + || string.Equals(formName, "Untitled Form", StringComparison.Ordinal) + || (!string.IsNullOrWhiteSpace(previousTableName) + && string.Equals(formName, $"{previousTableName} Form", StringComparison.Ordinal)); + private void OnNameKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index dad17947..79126d1c 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -15,6 +15,10 @@ This project is consumed by `CSharpDB.Admin`. It is not a standalone web host. - record paging, search, create, update, and delete services - validation rule inference and validation override support - child table/tab support for related records +- trusted command-backed form events and command buttons +- trusted command-backed selected-control events +- declarative action sequences for form and selected-control events +- generated automation metadata for import/export host callback requirements ## Main Components @@ -39,6 +43,31 @@ using CSharpDB.Admin.Forms.Services; builder.Services.AddCSharpDbAdminForms(); ``` +Trusted command callbacks can be registered with the overload: + +```csharp +builder.Services.AddCSharpDbAdminForms(commands => +{ + commands.AddAsyncCommand( + "AuditFormOpen", + new DbCommandOptions( + Description: "Writes a form audit entry.", + Timeout: TimeSpan.FromSeconds(5), + IsLongRunning: true), + async (context, ct) => + { + await WriteAuditAsync(context.Metadata["formName"], ct); + return DbCommandResult.Success(); + }); +}); +``` + +The Forms runtime passes the command cancellation token to trusted callbacks. +If a command timeout elapses, the runtime reports the timeout through the same +form-event failure path as other command errors. Command buttons refresh their +executing state around async callbacks so the clicked button is disabled while +the callback is in flight. + The extension registers: - `IFormRepository` @@ -46,6 +75,8 @@ The extension registers: - `IFormRecordService` - `IFormGenerator` - `IValidationInferenceService` +- `IFormEventDispatcher` +- `DbCommandRegistry` ## Core Contracts @@ -70,11 +101,43 @@ public sealed record FormDefinition( string SourceSchemaSignature, LayoutDefinition Layout, IReadOnlyList Controls, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null, + DbAutomationMetadata? Automation = null, + IReadOnlyList? ActionSequences = null); ``` Controls are stored as `ControlDefinition` records with geometry, binding, -properties, optional validation overrides, and optional renderer hints. +properties, optional validation overrides, optional renderer hints, and optional +`ControlEventBinding` entries for selected control events such as `OnClick`, +`OnChange`, `OnGotFocus`, and `OnLostFocus`. + +Form and control event bindings can reference a trusted command name and can +optionally include a `DbActionSequence`. Forms can also store reusable named +action sequences in `ActionSequences`, and event/button sequences can invoke +them with `RunActionSequence`. Action sequences store declarative steps such as +`RunCommand`, `RunActionSequence`, `SetFieldValue`, `ShowMessage`, `Stop`, +`NewRecord`, `SaveRecord`, `DeleteRecord`, `RefreshRecords`, `PreviousRecord`, +`NextRecord`, and `GoToRecord`; they do not store C# source or serialized +delegates. The property inspector exposes a visual action-sequence editor on +form-level and selected-control event bindings plus a reusable action library +when editing form properties. JSON editing is limited to optional command or +nested-sequence argument payloads. + +The built-in record actions run only in the rendered Forms data-entry runtime. +Headless form event dispatch can still run command, field, message, and stop +steps, but navigation and save/delete actions require a rendered form instance. + +Every action step can also store a simple condition such as `Status = 'Ready'`, +`Amount > 0`, or `IsActive`. False conditions skip that step; malformed +conditions fail through the normal action failure path and honor +`StopOnFailure`. + +`DbFormRepository` regenerates `Automation` on save/load. The manifest records +trusted command and scalar-function names used by form events, command buttons, +selected-control events, reusable action sequences, action sequences, and +computed formulas so exported form JSON tells a host which callbacks it must +register. ## Build diff --git a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs index 29a46c75..4bea54cd 100644 --- a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs +++ b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Primitives; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace CSharpDB.Admin.Forms.Services; @@ -7,11 +9,65 @@ public static class AdminFormsServiceCollectionExtensions { public static IServiceCollection AddCSharpDbAdminForms(this IServiceCollection services) { + services.TryAddSingleton(DbCommandRegistry.Empty); + services.TryAddSingleton(DbValidationRuleRegistry.Empty); + services.TryAddSingleton(DbExtensionPolicies.DefaultHostCallbackPolicy); + services.TryAddSingleton(NullFormActionRuntime.Instance); + services.TryAddFormControlRegistry(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); return services; } + + public static IServiceCollection AddCSharpDbAdminForms( + this IServiceCollection services, + Action configureCommands) + { + ArgumentNullException.ThrowIfNull(configureCommands); + + services.AddSingleton(DbCommandRegistry.Create(configureCommands)); + return services.AddCSharpDbAdminForms(); + } + + public static IServiceCollection AddCSharpDbAdminFormValidationRules( + this IServiceCollection services, + Action configureRules) + { + ArgumentNullException.ThrowIfNull(configureRules); + + services.AddSingleton(DbValidationRuleRegistry.Create(configureRules)); + return services.AddCSharpDbAdminForms(); + } + + public static IServiceCollection AddCSharpDbAdminFormControls( + this IServiceCollection services, + Action configureControls) + { + ArgumentNullException.ThrowIfNull(configureControls); + + services.AddSingleton( + new DelegateFormControlRegistryConfiguration(configureControls)); + services.TryAddFormControlRegistry(); + return services; + } + + private static IServiceCollection TryAddFormControlRegistry(this IServiceCollection services) + { + services.TryAddSingleton(sp => + { + var builder = new FormControlRegistryBuilder(); + BuiltInFormControlDescriptors.AddTo(builder); + + foreach (IFormControlRegistryConfiguration configuration in sp.GetServices()) + configuration.Configure(builder); + + return builder.Build(); + }); + + return services; + } } diff --git a/src/CSharpDB.Admin.Forms/Services/BuiltInFormControlDescriptors.cs b/src/CSharpDB.Admin.Forms/Services/BuiltInFormControlDescriptors.cs new file mode 100644 index 00000000..76ba2205 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/BuiltInFormControlDescriptors.cs @@ -0,0 +1,133 @@ +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class BuiltInFormControlDescriptors +{ + public static void AddTo(FormControlRegistryBuilder builder) + { + builder + .Add(BuiltIn("label", "Label", "Layout", "A", 180, 34, supportsBinding: false, participatesInTabOrder: false, 10, 10, "Static text label", + new Dictionary { ["text"] = "Label" })) + .Add(BuiltIn("text", "Text", "Input Controls", "\u2328", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 10, "Text input field")) + .Add(BuiltIn("textarea", "Textarea", "Input Controls", "\u2263", 320, 80, supportsBinding: true, participatesInTabOrder: true, 20, 20, "Multi-line text area")) + .Add(BuiltIn("number", "Number", "Input Controls", "#", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 30, "Number input field")) + .Add(BuiltIn("date", "Date", "Input Controls", "\U0001F4C5", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 40, "Date picker")) + .Add(BuiltIn("checkbox", "Checkbox", "Input Controls", "\u2611", 200, 34, supportsBinding: true, participatesInTabOrder: true, 20, 50, "Checkbox", + new Dictionary { ["text"] = "Checkbox" })) + .Add(BuiltIn("radio", "Radio", "Input Controls", "\u25C9", 200, 80, supportsBinding: true, participatesInTabOrder: true, 20, 60, "Radio button group")) + .Add(BuiltIn("select", "Select", "Input Controls", "\u25BE", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 70, "Dropdown select", + ChoiceDefaults())) + .Add(BuiltIn("comboBox", "Combo Box", "Input Controls", "\u2327", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 80, "Searchable single-select with optional custom entry", + ChoiceDefaults(new Dictionary { ["placeholder"] = "Search or select", ["allowCustomValue"] = false }))) + .Add(BuiltIn("listBox", "List Box", "Input Controls", "\u2630", 260, 120, supportsBinding: true, participatesInTabOrder: true, 20, 90, "Always-visible single-select list", + ChoiceDefaults(new Dictionary { ["visibleRows"] = 5, ["multiSelect"] = false, ["multiValueDelimiter"] = ";" }))) + .Add(BuiltIn("lookup", "Lookup", "Input Controls", "\U0001F50D", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 100, "Lookup combo box loaded from a table", + new Dictionary { ["lookupTable"] = "", ["displayField"] = "", ["valueField"] = "", ["placeholder"] = "-- Select --" })) + .Add(BuiltIn("optionGroup", "Option Group", "Input Controls", "\u25C9", 220, 100, supportsBinding: true, participatesInTabOrder: true, 20, 110, "Bound scalar option group", + ChoiceDefaults(new Dictionary { ["orientation"] = "vertical", ["buttonStyle"] = false }))) + .Add(BuiltIn("toggleButton", "Toggle", "Input Controls", "\u25FC", 160, 34, supportsBinding: true, participatesInTabOrder: true, 20, 120, "Boolean or scalar toggle button", + new Dictionary { ["text"] = "Toggle", ["trueValue"] = true, ["falseValue"] = false })) + .Add(BuiltIn("computed", "Computed", "Input Controls", "\u03A3", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 130, "Computed field", + new Dictionary { ["formula"] = "", ["format"] = "" })) + .Add(BuiltIn("datagrid", "DataGrid", "Data", "\u2637", 560, 200, supportsBinding: false, participatesInTabOrder: false, 30, 10, "Table data grid", + new Dictionary + { + ["dataGridMode"] = "standalone", + ["childTable"] = "", + ["foreignKeyField"] = "", + ["parentKeyField"] = "", + ["foreignKeyName"] = "", + ["visibleColumns"] = Array.Empty(), + ["allowAdd"] = true, + ["allowDelete"] = true, + ["allowEdit"] = true, + })) + .Add(BuiltIn("childtabs", "Child Tabs", "Data", "\u2630", 600, 280, supportsBinding: false, participatesInTabOrder: false, 30, 20, "Tab-based child forms with nesting", + new Dictionary { ["tabs"] = Array.Empty() })) + .Add(BuiltIn("tabControl", "Tab Control", "Data", "\u25AB", 600, 300, supportsBinding: false, participatesInTabOrder: false, 30, 30, "General tab container for form controls", + new Dictionary + { + ["tabs"] = new object?[] + { + new Dictionary { ["id"] = "page1", ["label"] = "Page 1" }, + new Dictionary { ["id"] = "page2", ["label"] = "Page 2" }, + }, + })) + .Add(BuiltIn("subform", "Subform", "Data", "\u25A3", 640, 320, supportsBinding: false, participatesInTabOrder: false, 30, 40, "Embedded saved form linked to the parent record", + new Dictionary { ["formId"] = "", ["parentKeyField"] = "", ["foreignKeyField"] = "", ["showToolbar"] = true, ["showRecordList"] = true })) + .Add(BuiltIn("attachment", "Attachment", "Data", "\u2398", 360, 74, supportsBinding: true, participatesInTabOrder: true, 30, 50, "BLOB attachment upload", + AttachmentDefaults("attachment"))) + .Add(BuiltIn("image", "Image", "Data", "\u25A7", 360, 240, supportsBinding: true, participatesInTabOrder: true, 30, 60, "BLOB image upload and preview", + AttachmentDefaults("image"))) + .Add(BuiltIn("commandButton", "Button", "Automation", "\u25B6", 160, 34, supportsBinding: false, participatesInTabOrder: true, 40, 10, "Runs a trusted command", + new Dictionary { ["text"] = "Button", ["commandName"] = "" })); + } + + private static FormControlDescriptor BuiltIn( + string controlType, + string displayName, + string toolboxGroup, + string iconText, + double defaultWidth, + double defaultHeight, + bool supportsBinding, + bool participatesInTabOrder, + int groupOrder, + int order, + string description, + IReadOnlyDictionary? defaultProps = null) + => new() + { + ControlType = controlType, + DisplayName = displayName, + ToolboxGroup = toolboxGroup, + IconText = iconText, + Description = description, + DefaultWidth = defaultWidth, + DefaultHeight = defaultHeight, + SupportsBinding = supportsBinding, + ParticipatesInTabOrder = participatesInTabOrder, + ToolboxGroupOrder = groupOrder, + ToolboxOrder = order, + DefaultProps = defaultProps ?? new Dictionary(), + IsBuiltIn = true, + }; + + private static Dictionary ChoiceDefaults(Dictionary? extra = null) + { + var props = new Dictionary + { + ["options"] = new object?[] + { + new Dictionary { ["value"] = "1", ["label"] = "Option 1" }, + new Dictionary { ["value"] = "2", ["label"] = "Option 2" }, + }, + }; + + if (extra is not null) + { + foreach (KeyValuePair pair in extra) + props[pair.Key] = pair.Value; + } + + return props; + } + + private static Dictionary AttachmentDefaults(string controlType) + => new() + { + ["storageMode"] = "blobField", + ["fileNameField"] = "", + ["contentTypeField"] = "", + ["fileSizeField"] = "", + ["attachmentTable"] = "", + ["attachmentForeignKeyField"] = "", + ["attachmentBlobField"] = "", + ["attachmentFileNameField"] = "", + ["attachmentContentTypeField"] = "", + ["attachmentFileSizeField"] = "", + ["attachmentControlIdField"] = "", + ["accept"] = controlType == "image" ? "image/*" : "", + }; +} diff --git a/src/CSharpDB.Admin.Forms/Services/DbFormRecordService.cs b/src/CSharpDB.Admin.Forms/Services/DbFormRecordService.cs index 19ddd68d..29f2c04c 100644 --- a/src/CSharpDB.Admin.Forms/Services/DbFormRecordService.cs +++ b/src/CSharpDB.Admin.Forms/Services/DbFormRecordService.cs @@ -297,6 +297,45 @@ SELECT COUNT(*) AS RowCount ?? throw new InvalidOperationException("The updated record could not be reloaded."); } + public async Task SaveAttachmentAsync(FormAttachmentTableBinding binding, object parentValue, FormAttachmentValue attachment, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(binding); + ArgumentNullException.ThrowIfNull(parentValue); + ArgumentNullException.ThrowIfNull(attachment); + + string tableName = FormSql.RequireIdentifier(binding.TableName, nameof(binding.TableName)); + string foreignKeyField = FormSql.RequireIdentifier(binding.ForeignKeyField, nameof(binding.ForeignKeyField)); + string blobField = FormSql.RequireIdentifier(binding.BlobField, nameof(binding.BlobField)); + string whereClause = $"{foreignKeyField} = {FormSql.FormatLiteral(parentValue)}"; + + if (!string.IsNullOrWhiteSpace(binding.ControlIdField) && !string.IsNullOrWhiteSpace(binding.ControlId)) + { + string controlIdField = FormSql.RequireIdentifier(binding.ControlIdField, nameof(binding.ControlIdField)); + whereClause += $" AND {controlIdField} = {FormSql.FormatLiteral(binding.ControlId)}"; + } + + FormSql.ThrowIfError(await dbClient.ExecuteSqlAsync($""" + DELETE FROM {tableName} + WHERE {whereClause}; + """, ct)); + + if (attachment.ClearExisting || attachment.Bytes is not { Length: > 0 } bytes) + return; + + var values = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [binding.ForeignKeyField] = parentValue, + [binding.BlobField] = bytes, + }; + + AddOptionalAttachmentValue(values, binding.FileNameField, attachment.FileName); + AddOptionalAttachmentValue(values, binding.ContentTypeField, attachment.ContentType); + AddOptionalAttachmentValue(values, binding.FileSizeField, attachment.FileSize); + AddOptionalAttachmentValue(values, binding.ControlIdField, binding.ControlId); + + await dbClient.InsertRowAsync(binding.TableName, values, ct); + } + public Task DeleteRecordAsync(FormTableDefinition table, object pkValue, CancellationToken ct = default) => dbClient.DeleteRowAsync(table.TableName, GetPrimaryKeyColumn(table), pkValue, ct); @@ -431,6 +470,12 @@ private static int FindRecordIndex(IReadOnlyList> re return filtered; } + private static void AddOptionalAttachmentValue(Dictionary values, string? fieldName, object? value) + { + if (!string.IsNullOrWhiteSpace(fieldName)) + values[fieldName] = value; + } + private static Dictionary BuildEmptyInsertValues(FormTableDefinition table) { string pkColumn = GetSinglePrimaryKeyColumn(table); diff --git a/src/CSharpDB.Admin.Forms/Services/DbFormRepository.cs b/src/CSharpDB.Admin.Forms/Services/DbFormRepository.cs index ea9913e3..72f0f579 100644 --- a/src/CSharpDB.Admin.Forms/Services/DbFormRepository.cs +++ b/src/CSharpDB.Admin.Forms/Services/DbFormRepository.cs @@ -167,30 +167,33 @@ private static FormDefinition DeserializeForm(Dictionary row) string json = row["definition_json"]?.ToString() ?? throw new InvalidOperationException("Stored form definition is missing JSON."); - return JsonSerializer.Deserialize(json, JsonDefaults.Options) + FormDefinition form = JsonSerializer.Deserialize(json, JsonDefaults.Options) ?? throw new InvalidOperationException("Stored form definition JSON could not be deserialized."); + return FormAutomationMetadata.NormalizeForExport(form); } private static FormDefinition NormalizeForCreate(FormDefinition form) { ValidateForPersistence(form); - return form with + FormDefinition stored = form with { FormId = string.IsNullOrWhiteSpace(form.FormId) ? Guid.NewGuid().ToString("N") : form.FormId, Name = string.IsNullOrWhiteSpace(form.Name) ? $"{form.TableName} Form" : form.Name.Trim(), DefinitionVersion = 1, }; + return FormAutomationMetadata.NormalizeForExport(stored); } private static FormDefinition NormalizeForUpdate(string formId, int expectedVersion, FormDefinition form) { ValidateForPersistence(form); - return form with + FormDefinition stored = form with { FormId = formId, Name = string.IsNullOrWhiteSpace(form.Name) ? $"{form.TableName} Form" : form.Name.Trim(), DefinitionVersion = expectedVersion + 1, }; + return FormAutomationMetadata.NormalizeForExport(stored); } private static void ValidateForPersistence(FormDefinition form) diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormControlRegistry.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormControlRegistry.cs new file mode 100644 index 00000000..f1681f73 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormControlRegistry.cs @@ -0,0 +1,15 @@ +using CSharpDB.Admin.Forms.Contracts; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class DefaultFormControlRegistry +{ + public static IFormControlRegistry Instance { get; } = Create(); + + private static IFormControlRegistry Create() + { + var builder = new FormControlRegistryBuilder(); + BuiltInFormControlDescriptors.AddTo(builder); + return builder.Build(); + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs new file mode 100644 index 00000000..0db65f52 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs @@ -0,0 +1,133 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class DefaultFormEventDispatcher : IFormEventDispatcher +{ + private readonly DbCommandRegistry _commands; + private readonly DbExtensionPolicy _callbackPolicy; + private readonly IFormActionRuntime _actionRuntime; + + public DefaultFormEventDispatcher(DbCommandRegistry commands) + : this(commands, DbExtensionPolicies.DefaultHostCallbackPolicy, NullFormActionRuntime.Instance) + { + } + + public DefaultFormEventDispatcher(DbCommandRegistry commands, IFormActionRuntime actionRuntime) + : this(commands, DbExtensionPolicies.DefaultHostCallbackPolicy, actionRuntime) + { + } + + public DefaultFormEventDispatcher( + DbCommandRegistry commands, + DbExtensionPolicy callbackPolicy, + IFormActionRuntime actionRuntime) + { + ArgumentNullException.ThrowIfNull(commands); + ArgumentNullException.ThrowIfNull(callbackPolicy); + ArgumentNullException.ThrowIfNull(actionRuntime); + + _commands = commands; + _callbackPolicy = callbackPolicy; + _actionRuntime = actionRuntime; + } + + public async Task DispatchAsync( + FormDefinition form, + FormEventKind eventKind, + IReadOnlyDictionary? record = null, + CancellationToken ct = default) + => await DispatchAsync(form, eventKind, record, _actionRuntime, ct); + + public async Task DispatchAsync( + FormDefinition form, + FormEventKind eventKind, + IReadOnlyDictionary? record, + IFormActionRuntime actionRuntime, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(form); + ArgumentNullException.ThrowIfNull(actionRuntime); + + IReadOnlyList bindings = form.EventBindings ?? []; + foreach (FormEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) + { + Dictionary metadata = FormCommandInvocation.BuildMetadata(form); + metadata["event"] = eventKind.ToString(); + + if (!string.IsNullOrWhiteSpace(binding.CommandName)) + { + if (!_commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) + { + string message = $"Unknown form command '{binding.CommandName}' for event '{eventKind}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(binding.CommandName, metadata, message); + return FormEventDispatchResult.Failure(message); + } + + Dictionary arguments = FormCommandInvocation.BuildArguments(record, binding.Arguments); + bool commandFailed = false; + string? commandFailureMessage = null; + try + { + DbCommandResult result = await definition.InvokeAsync( + arguments, + metadata, + _callbackPolicy, + DbExtensionHostMode.Embedded, + ct); + if (!result.Succeeded) + { + commandFailed = true; + commandFailureMessage = string.IsNullOrWhiteSpace(result.Message) + ? $"Form event '{eventKind}' command '{definition.Name}' failed." + : result.Message; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + commandFailed = true; + commandFailureMessage = $"Form event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"; + } + + if (commandFailed) + { + if (binding.StopOnFailure) + return FormEventDispatchResult.Failure(commandFailureMessage!); + + if (binding.ActionSequence is null) + continue; + } + } + else if (binding.ActionSequence is null) + { + return FormEventDispatchResult.Failure($"Form event '{eventKind}' has no command or action sequence."); + } + + if (binding.ActionSequence is not null) + { + FormEventDispatchResult actionResult = await FormActionSequenceExecutor.ExecuteAsync( + binding.ActionSequence, + _commands, + record, + binding.Arguments, + runtimeArguments: null, + metadata, + reusableSequences: form.ActionSequences, + actionRuntime: actionRuntime, + callbackPolicy: _callbackPolicy, + ct: ct); + + if (!actionResult.Succeeded && binding.StopOnFailure) + return actionResult; + } + } + + return FormEventDispatchResult.Success(); + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormGenerator.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormGenerator.cs index 57d27b9a..022d20cb 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultFormGenerator.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormGenerator.cs @@ -63,6 +63,7 @@ private static string PickControlType(FormFieldDefinition field) { FieldDataType.Boolean => "checkbox", FieldDataType.Date or FieldDataType.DateTime => "date", + FieldDataType.Blob => "attachment", FieldDataType.Int32 or FieldDataType.Int64 or FieldDataType.Decimal or FieldDataType.Double => "number", _ => "text", }; diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultValidationInferenceService.cs b/src/CSharpDB.Admin.Forms/Services/DefaultValidationInferenceService.cs index c9ebdccb..1cd4fd90 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultValidationInferenceService.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultValidationInferenceService.cs @@ -1,10 +1,39 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Services; public sealed class DefaultValidationInferenceService : IValidationInferenceService { + private static readonly HashSet BuiltInRuleIds = new(StringComparer.OrdinalIgnoreCase) + { + "required", + "maxLength", + "range", + "regex", + "oneOf", + }; + + private readonly DbValidationRuleRegistry _validationRules; + private readonly DbExtensionPolicy _callbackPolicy; + + public DefaultValidationInferenceService() + : this(DbValidationRuleRegistry.Empty, DbExtensionPolicies.DefaultHostCallbackPolicy) + { + } + + public DefaultValidationInferenceService( + DbValidationRuleRegistry validationRules, + DbExtensionPolicy callbackPolicy) + { + _validationRules = validationRules; + _callbackPolicy = callbackPolicy; + } + public IReadOnlyList InferRules(FormFieldDefinition field) { var rules = new List(); @@ -67,46 +96,449 @@ public IReadOnlyList InferRules(FormFieldDefinition field) } public IReadOnlyList Evaluate(FormDefinition form, IDictionary record) + => EvaluateAsync(form, record).GetAwaiter().GetResult(); + + public async Task> EvaluateAsync( + FormDefinition form, + IDictionary record, + CancellationToken ct = default) { + ArgumentNullException.ThrowIfNull(form); + ArgumentNullException.ThrowIfNull(record); + var errors = new List(); + Dictionary recordValues = DbCommandArguments.FromObjectDictionary( + new Dictionary(record, StringComparer.OrdinalIgnoreCase)); - foreach (var control in form.Controls) + foreach (ControlDefinition control in form.Controls) { if (control.Binding is null) continue; - if (control.ValidationOverride?.DisableInferredRules == true) - continue; - string fieldName = control.Binding.FieldName; record.TryGetValue(fieldName, out object? value); - var disabledIds = control.ValidationOverride?.DisableRuleIds ?? []; - - if (!disabledIds.Contains("maxLength") && - control.Props.Values.TryGetValue("maxLength", out object? maxLength) && - value is string text && - maxLength is long max && - text.Length > max) - { - errors.Add(new ValidationError(fieldName, "maxLength", $"{fieldName} must be at most {max} characters.")); - } + IReadOnlyList disabledIds = control.ValidationOverride?.DisableRuleIds ?? []; - if (control.ValidationOverride?.AddRules is not { } addedRules) - continue; + if (control.ValidationOverride?.DisableInferredRules != true) + AddInferredControlErrors(errors, control, fieldName, value, disabledIds); - foreach (var rule in addedRules) + foreach (ValidationRule rule in control.ValidationOverride?.AddRules ?? []) { - if (disabledIds.Contains(rule.RuleId)) + if (disabledIds.Contains(rule.RuleId, StringComparer.OrdinalIgnoreCase)) continue; - if (rule.RuleId == "required" && IsEmpty(value)) - errors.Add(new ValidationError(fieldName, rule.RuleId, rule.Message)); + if (ApplyBuiltInRule(errors, rule, fieldName, value)) + continue; + + await InvokeValidationRuleAsync( + errors, + form, + control, + fieldName, + value, + recordValues, + rule, + DbValidationRuleScope.Field, + ct).ConfigureAwait(false); } } + foreach (ValidationRule rule in form.ValidationRules ?? []) + { + await InvokeValidationRuleAsync( + errors, + form, + control: null, + fieldName: null, + value: null, + recordValues, + rule, + DbValidationRuleScope.Form, + ct).ConfigureAwait(false); + } + return errors; } + private static void AddInferredControlErrors( + List errors, + ControlDefinition control, + string fieldName, + object? value, + IReadOnlyList disabledIds) + { + if (!disabledIds.Contains("required", StringComparer.OrdinalIgnoreCase) && + TryGetBoolean(control.Props.Values, "required", out bool required) && + required && + IsEmpty(value)) + { + errors.Add(new ValidationError(fieldName, "required", $"{fieldName} is required.")); + } + + if (!disabledIds.Contains("maxLength", StringComparer.OrdinalIgnoreCase) && + TryGetLong(control.Props.Values, "maxLength", out long maxLength) && + value is string text && + text.Length > maxLength) + { + errors.Add(new ValidationError(fieldName, "maxLength", $"{fieldName} must be at most {maxLength} characters.")); + } + + if (!disabledIds.Contains("range", StringComparer.OrdinalIgnoreCase) && + TryGetDoubleValue(value, out double numericValue)) + { + bool hasMin = TryGetDouble(control.Props.Values, "min", out double min); + bool hasMax = TryGetDouble(control.Props.Values, "max", out double maxValue); + if (hasMin && numericValue < min) + errors.Add(new ValidationError(fieldName, "range", $"{fieldName} must be at least {min.ToString(CultureInfo.InvariantCulture)}.")); + if (hasMax && numericValue > maxValue) + errors.Add(new ValidationError(fieldName, "range", $"{fieldName} must be at most {maxValue.ToString(CultureInfo.InvariantCulture)}.")); + } + + if (!disabledIds.Contains("regex", StringComparer.OrdinalIgnoreCase) && + TryGetString(control.Props.Values, "pattern", out string? pattern) && + pattern is not null && + value is string stringValue && + !Regex.IsMatch(stringValue, pattern)) + { + errors.Add(new ValidationError(fieldName, "regex", $"{fieldName} has an invalid format.")); + } + } + + private static bool ApplyBuiltInRule( + List errors, + ValidationRule rule, + string fieldName, + object? value) + { + if (!BuiltInRuleIds.Contains(rule.RuleId)) + return false; + + switch (rule.RuleId) + { + case "required" when IsEmpty(value): + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} is required."))); + break; + + case "maxLength" when value is string text && TryGetLong(rule.Parameters, "max", out long maxLength) && text.Length > maxLength: + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} must be at most {maxLength} characters."))); + break; + + case "range" when TryGetDoubleValue(value, out double numericValue): + bool hasMin = TryGetDouble(rule.Parameters, "min", out double min); + bool hasMax = TryGetDouble(rule.Parameters, "max", out double maxValue); + if ((hasMin && numericValue < min) || (hasMax && numericValue > maxValue)) + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} is outside the allowed range."))); + break; + + case "regex" when value is string stringValue && TryGetString(rule.Parameters, "pattern", out string? pattern) && pattern is not null && !Regex.IsMatch(stringValue, pattern): + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} has an invalid format."))); + break; + + case "oneOf" when !IsOneOfAllowedValue(value, rule.Parameters): + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} must be one of the allowed values."))); + break; + } + + return true; + } + + private async Task InvokeValidationRuleAsync( + List errors, + FormDefinition form, + ControlDefinition? control, + string? fieldName, + object? value, + IReadOnlyDictionary recordValues, + ValidationRule rule, + DbValidationRuleScope scope, + CancellationToken ct) + { + string ruleName = rule.RuleId?.Trim() ?? string.Empty; + string defaultFieldName = fieldName ?? string.Empty; + IReadOnlyDictionary metadata = CreateValidationMetadata(form, control, ruleName, scope); + + if (string.IsNullOrWhiteSpace(ruleName)) + { + errors.Add(new ValidationError(defaultFieldName, string.Empty, "Validation rule is missing a rule name.")); + return; + } + + if (!_validationRules.TryGetRule(ruleName, out DbValidationRuleDefinition? definition)) + { + string message = $"Validation rule '{ruleName}' is not registered in the current Admin host."; + DbCallbackDiagnostics.WriteMissingValidationInvocation(ruleName, metadata, message); + errors.Add(new ValidationError(defaultFieldName, ruleName, message)); + return; + } + + var context = DbValidationRuleContext.Create( + ruleName, + scope, + recordValues, + DbCommandArguments.FromObjectDictionary(rule.Parameters), + metadata) with + { + FormId = form.FormId, + FormName = form.Name, + TableName = form.TableName, + ControlId = control?.ControlId, + FieldName = fieldName, + Value = DbCommandArguments.FromObject(value), + FallbackMessage = rule.Message, + }; + + try + { + DbValidationRuleResult result = await definition + .InvokeAsync(context, _callbackPolicy, DbExtensionHostMode.Embedded, ct) + .ConfigureAwait(false); + + if (!result.Succeeded || result.Failures is { Count: > 0 }) + AddValidationRuleFailures(errors, result, defaultFieldName, ruleName, rule.Message); + } + catch (DbCallbackPolicyException ex) + { + string reason = ex.Decision.DenialReason ?? ex.Message; + errors.Add(new ValidationError(defaultFieldName, ruleName, $"Validation rule '{ruleName}' was denied by policy: {reason}")); + } + catch (TimeoutException ex) + { + errors.Add(new ValidationError(defaultFieldName, ruleName, ex.Message)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + errors.Add(new ValidationError(defaultFieldName, ruleName, $"Validation rule '{ruleName}' failed: {ex.Message}")); + } + } + + private static void AddValidationRuleFailures( + List errors, + DbValidationRuleResult result, + string defaultFieldName, + string ruleName, + string fallbackMessage) + { + if (result.Failures is { Count: > 0 } failures) + { + foreach (DbValidationFailure failure in failures) + { + string fieldName = failure.FieldName ?? string.Empty; + errors.Add(new ValidationError( + fieldName, + failure.RuleId ?? ruleName, + string.IsNullOrWhiteSpace(failure.Message) ? GetFallbackFailureMessage(ruleName, fallbackMessage, result.Message) : failure.Message)); + } + + return; + } + + errors.Add(new ValidationError( + defaultFieldName, + ruleName, + GetFallbackFailureMessage(ruleName, fallbackMessage, result.Message))); + } + + private static string GetFallbackFailureMessage(string ruleName, string fallbackMessage, string? resultMessage) + { + if (!string.IsNullOrWhiteSpace(resultMessage)) + return resultMessage; + if (!string.IsNullOrWhiteSpace(fallbackMessage)) + return fallbackMessage; + + return $"Validation rule '{ruleName}' failed."; + } + + private static IReadOnlyDictionary CreateValidationMetadata( + FormDefinition form, + ControlDefinition? control, + string ruleName, + DbValidationRuleScope scope) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "admin.forms", + ["ownerKind"] = "Form", + ["ownerId"] = form.FormId, + ["ownerName"] = form.Name, + ["formId"] = form.FormId, + ["formName"] = form.Name, + ["tableName"] = form.TableName, + ["validationScope"] = scope.ToString(), + ["ruleName"] = ruleName, + ["correlationId"] = Guid.NewGuid().ToString("N"), + }; + + if (scope == DbValidationRuleScope.Field && control is not null) + { + metadata["controlId"] = control.ControlId; + if (control.Binding?.FieldName is { Length: > 0 } fieldName) + metadata["fieldName"] = fieldName; + metadata["location"] = $"controls.{control.ControlId}.validationRules.{ruleName}"; + } + else + { + metadata["location"] = $"form.validationRules.{ruleName}"; + } + + return metadata; + } + + private static string GetRuleMessage(ValidationRule rule, string fallback) + => string.IsNullOrWhiteSpace(rule.Message) ? fallback : rule.Message; + private static bool IsEmpty(object? value) => value is null or "" || value is string text && string.IsNullOrWhiteSpace(text); + + private static bool TryGetBoolean(IReadOnlyDictionary values, string key, out bool result) + { + result = false; + if (!values.TryGetValue(key, out object? value) || value is null) + return false; + + if (value is bool boolValue) + { + result = boolValue; + return true; + } + + if (value is JsonElement json) + { + if (json.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + result = json.GetBoolean(); + return true; + } + + if (json.ValueKind == JsonValueKind.String) + value = json.GetString(); + } + + if (value is null) + return false; + + string? text = value.ToString(); + return text is not null && bool.TryParse(text, out result); + } + + private static bool TryGetString(IReadOnlyDictionary values, string key, out string? result) + { + result = null; + if (!values.TryGetValue(key, out object? value) || value is null) + return false; + + if (value is JsonElement json) + value = json.ValueKind == JsonValueKind.String ? json.GetString() : json.ToString(); + + result = value?.ToString(); + return !string.IsNullOrWhiteSpace(result); + } + + private static bool TryGetLong(IReadOnlyDictionary values, string key, out long result) + { + result = 0; + if (!values.TryGetValue(key, out object? value)) + return false; + + return TryGetLongValue(value, out result); + } + + private static bool TryGetDouble(IReadOnlyDictionary values, string key, out double result) + { + result = 0; + if (!values.TryGetValue(key, out object? value)) + return false; + + return TryGetDoubleValue(value, out result); + } + + private static bool TryGetLongValue(object? value, out long result) + { + result = 0; + if (value is null) + return false; + + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Number) + return json.TryGetInt64(out result); + if (json.ValueKind == JsonValueKind.String) + value = json.GetString(); + } + + if (value is null) + return false; + + if (value is IConvertible) + return long.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Integer, CultureInfo.InvariantCulture, out result); + + string? text = value.ToString(); + return text is not null && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out result); + } + + private static bool TryGetDoubleValue(object? value, out double result) + { + result = 0; + if (value is null) + return false; + + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Number) + return json.TryGetDouble(out result); + if (json.ValueKind == JsonValueKind.String) + value = json.GetString(); + } + + if (value is null) + return false; + + if (value is IConvertible) + return double.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out result); + + string? text = value.ToString(); + return text is not null && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out result); + } + + private static bool IsOneOfAllowedValue(object? value, IReadOnlyDictionary parameters) + { + if (!parameters.TryGetValue("values", out object? rawValues) || rawValues is null) + return true; + + string candidate = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + foreach (object? allowed in EnumerateValues(rawValues)) + { + string allowedText = Convert.ToString(allowed, CultureInfo.InvariantCulture) ?? string.Empty; + if (string.Equals(candidate, allowedText, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static IEnumerable EnumerateValues(object value) + { + if (value is JsonElement json && json.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in json.EnumerateArray()) + yield return item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString(); + yield break; + } + + if (value is IEnumerable objectValues) + { + foreach (object? item in objectValues) + yield return item; + yield break; + } + + if (value is System.Collections.IEnumerable values && value is not string) + { + foreach (object? item in values) + yield return item; + } + } } diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionConditionEvaluator.cs b/src/CSharpDB.Admin.Forms/Services/FormActionConditionEvaluator.cs new file mode 100644 index 00000000..4c6c1c5a --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormActionConditionEvaluator.cs @@ -0,0 +1,307 @@ +using System.Globalization; +using System.Text.Json; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class FormActionConditionEvaluator +{ + private static readonly string[] Operators = [">=", "<=", "==", "!=", "<>", "=", ">", "<"]; + + public static bool TryEvaluate( + string? condition, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary? stepArguments, + out bool result, + out string? error) + { + result = true; + error = null; + + if (string.IsNullOrWhiteSpace(condition)) + return true; + + string expression = condition.Trim(); + if (expression.StartsWith('=') && !expression.StartsWith("==", StringComparison.Ordinal)) + expression = expression[1..].Trim(); + + if (expression.Length == 0) + { + error = "Condition is empty."; + return false; + } + + Dictionary values = BuildValues(record, bindingArguments, runtimeArguments, stepArguments); + if (TryFindOperator(expression, out int operatorIndex, out string? op)) + { + string leftText = expression[..operatorIndex].Trim(); + string rightText = expression[(operatorIndex + op.Length)..].Trim(); + if (leftText.Length == 0 || rightText.Length == 0) + { + error = $"Condition '{condition}' is missing a comparison operand."; + return false; + } + + if (!TryResolveValue(leftText, values, requireKnownIdentifier: true, out object? left, out error) || + !TryResolveValue(rightText, values, requireKnownIdentifier: false, out object? right, out error)) + { + return false; + } + + result = Compare(left, right, op); + return true; + } + + if (!TryResolveValue(expression, values, requireKnownIdentifier: true, out object? value, out error)) + return false; + + result = IsTruthy(value); + return true; + } + + private static Dictionary BuildValues(params IReadOnlyDictionary?[] sources) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (IReadOnlyDictionary? source in sources) + { + if (source is null) + continue; + + foreach ((string key, object? value) in source) + values[key] = NormalizeValue(value); + } + + return values; + } + + private static bool TryFindOperator(string expression, out int index, out string op) + { + bool inSingleQuote = false; + bool inDoubleQuote = false; + bool inBracket = false; + + for (int i = 0; i < expression.Length; i++) + { + char ch = expression[i]; + if (ch == '\'' && !inDoubleQuote && !inBracket) + { + inSingleQuote = !inSingleQuote; + continue; + } + + if (ch == '"' && !inSingleQuote && !inBracket) + { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (ch == '[' && !inSingleQuote && !inDoubleQuote) + { + inBracket = true; + continue; + } + + if (ch == ']' && inBracket) + { + inBracket = false; + continue; + } + + if (inSingleQuote || inDoubleQuote || inBracket) + continue; + + foreach (string candidate in Operators) + { + if (expression.AsSpan(i).StartsWith(candidate, StringComparison.Ordinal)) + { + index = i; + op = candidate; + return true; + } + } + } + + index = -1; + op = string.Empty; + return false; + } + + private static bool TryResolveValue( + string token, + IReadOnlyDictionary values, + bool requireKnownIdentifier, + out object? value, + out string? error) + { + error = null; + token = token.Trim(); + if (token.Length == 0) + { + value = null; + error = "Condition contains an empty value."; + return false; + } + + if (TryReadQuotedString(token, out string? quoted)) + { + value = quoted; + return true; + } + + if (token.StartsWith('[') && token.EndsWith(']') && token.Length > 2) + { + string name = token[1..^1].Trim(); + if (values.TryGetValue(name, out value)) + return true; + + error = $"Condition references unknown value '{name}'."; + return false; + } + + if (string.Equals(token, "null", StringComparison.OrdinalIgnoreCase)) + { + value = null; + return true; + } + + if (bool.TryParse(token, out bool boolean)) + { + value = boolean; + return true; + } + + if (long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer)) + { + value = integer; + return true; + } + + if (double.TryParse(token, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double real)) + { + value = real; + return true; + } + + if (IsIdentifier(token)) + { + if (values.TryGetValue(token, out value)) + return true; + + if (requireKnownIdentifier) + { + error = $"Condition references unknown value '{token}'."; + return false; + } + } + + value = token; + return true; + } + + private static bool TryReadQuotedString(string token, out string? value) + { + value = null; + if (token.Length < 2) + return false; + + char quote = token[0]; + if ((quote != '\'' && quote != '"') || token[^1] != quote) + return false; + + value = token[1..^1].Replace($"{quote}{quote}", quote.ToString(), StringComparison.Ordinal); + return true; + } + + private static bool Compare(object? left, object? right, string op) + { + int comparison = CompareValues(left, right); + return op switch + { + "=" or "==" => comparison == 0, + "!=" or "<>" => comparison != 0, + ">" => comparison > 0, + ">=" => comparison >= 0, + "<" => comparison < 0, + "<=" => comparison <= 0, + _ => false, + }; + } + + private static int CompareValues(object? left, object? right) + { + if (left is null || right is null) + return left is null && right is null ? 0 : left is null ? -1 : 1; + + if (TryConvertDouble(left, out double leftNumber) && + TryConvertDouble(right, out double rightNumber)) + { + return leftNumber.CompareTo(rightNumber); + } + + if (left is bool leftBool && right is bool rightBool) + return leftBool.CompareTo(rightBool); + + return string.Compare( + Convert.ToString(left, CultureInfo.InvariantCulture), + Convert.ToString(right, CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTruthy(object? value) + { + value = NormalizeValue(value); + if (value is null) + return false; + + if (value is bool boolean) + return boolean; + + if (TryConvertDouble(value, out double number)) + return Math.Abs(number) > double.Epsilon; + + return !string.IsNullOrWhiteSpace(Convert.ToString(value, CultureInfo.InvariantCulture)); + } + + private static bool TryConvertDouble(object? value, out double result) + { + value = NormalizeValue(value); + return value switch + { + byte number => Set(number, out result), + short number => Set(number, out result), + int number => Set(number, out result), + long number => Set(number, out result), + float number => Set(number, out result), + double number => Set(number, out result), + decimal number => Set((double)number, out result), + string text => double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out result), + _ => Set(0, out result, success: false), + }; + } + + private static bool Set(double value, out double result, bool success = true) + { + result = value; + return success; + } + + private static object? NormalizeValue(object? value) + => value is JsonElement json ? NormalizeJsonValue(json) : value; + + private static object? NormalizeJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + _ => value.ToString(), + }; + + private static bool IsIdentifier(string value) + => value.Length > 0 + && (char.IsLetter(value[0]) || value[0] == '_') + && value.All(static ch => char.IsLetterOrDigit(ch) || ch == '_'); +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs b/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs new file mode 100644 index 00000000..d06e70fd --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs @@ -0,0 +1,603 @@ +using System.Text.Json; +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +public static class FormActionManifestValidator +{ + private static readonly HashSet s_supportedControlProperties = new(StringComparer.OrdinalIgnoreCase) + { + "visible", + "enabled", + "readOnly", + "required", + "styleVariant", + "validationMessage", + "text", + "value", + "placeholder", + }; + + public static FormActionValidationResult Validate( + FormDefinition form, + FormActionValidationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(form); + + options ??= new FormActionValidationOptions(); + FormActionRuntimeCapabilities capabilities = options.RuntimeCapabilities ?? FormActionRuntimeCapabilities.None; + var issues = new List(); + var controlIds = new HashSet( + form.Controls.Select(static control => control.ControlId), + StringComparer.OrdinalIgnoreCase); + var sequenceNames = BuildSequenceNameIndex(form.ActionSequences, issues); + HashSet? availableForms = options.AvailableForms is null + ? null + : new HashSet(options.AvailableForms, StringComparer.OrdinalIgnoreCase); + HashSet? availableProcedures = options.AvailableProcedures is null + ? null + : new HashSet(options.AvailableProcedures, StringComparer.OrdinalIgnoreCase); + + foreach (FormEventBinding binding in form.EventBindings ?? []) + { + if (binding.ActionSequence is not null) + { + ValidateSequence( + binding.ActionSequence, + $"form.events.{binding.Event}.actionSequence", + binding.Event.ToString(), + controlIds, + sequenceNames, + availableForms, + availableProcedures, + options.Schema, + capabilities, + issues); + } + } + + foreach (ControlDefinition control in form.Controls) + { + foreach (ControlEventBinding binding in control.EventBindings ?? []) + { + if (binding.ActionSequence is not null) + { + ValidateSequence( + binding.ActionSequence, + $"controls.{control.ControlId}.events.{binding.Event}.actionSequence", + binding.Event.ToString(), + controlIds, + sequenceNames, + availableForms, + availableProcedures, + options.Schema, + capabilities, + issues); + } + } + } + + foreach (DbActionSequence sequence in form.ActionSequences ?? []) + { + string location = string.IsNullOrWhiteSpace(sequence.Name) + ? "form.actionSequences.unnamed" + : $"form.actionSequences.{sequence.Name}"; + ValidateSequence( + sequence, + location, + eventName: null, + controlIds, + sequenceNames, + availableForms, + availableProcedures, + options.Schema, + capabilities, + issues); + } + + ValidateRules(form.Rules, controlIds, options.Schema, issues); + + return new FormActionValidationResult( + !issues.Any(static issue => issue.Severity == FormActionValidationSeverity.Error), + issues.ToArray()); + } + + private static Dictionary BuildSequenceNameIndex( + IReadOnlyList? sequences, + List issues) + { + var names = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DbActionSequence sequence in sequences ?? []) + { + if (string.IsNullOrWhiteSpace(sequence.Name)) + continue; + + string name = sequence.Name.Trim(); + names[name] = names.TryGetValue(name, out int count) ? count + 1 : 1; + } + + foreach ((string name, int count) in names.Where(static pair => pair.Value > 1)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.RunActionSequence, + "admin.forms", + $"form.actionSequences.{name}", + $"Form action sequence name '{name}' is ambiguous because {count} sequences use it.", + Target: name, + ActionSequence: name)); + } + + return names; + } + + private static void ValidateSequence( + DbActionSequence sequence, + string sequenceLocation, + string? eventName, + HashSet controlIds, + Dictionary sequenceNames, + HashSet? availableForms, + HashSet? availableProcedures, + FormTableDefinition? schema, + FormActionRuntimeCapabilities capabilities, + List issues) + { + IReadOnlyList steps = sequence.Steps ?? []; + for (int i = 0; i < steps.Count; i++) + { + DbActionStep step = steps[i]; + string location = $"{sequenceLocation}.steps[{i}]"; + ValidateStep( + step, + location, + eventName, + sequence.Name, + i, + controlIds, + sequenceNames, + availableForms, + availableProcedures, + schema, + capabilities, + issues); + } + } + + private static void ValidateStep( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + Dictionary sequenceNames, + HashSet? availableForms, + HashSet? availableProcedures, + FormTableDefinition? schema, + FormActionRuntimeCapabilities capabilities, + List issues) + { + switch (step.Kind) + { + case DbActionKind.RunCommand: + if (string.IsNullOrWhiteSpace(step.CommandName)) + AddError(issues, step, location, "RunCommand action requires a command name.", eventName, actionSequence, stepIndex); + break; + case DbActionKind.SetFieldValue: + if (string.IsNullOrWhiteSpace(step.Target)) + AddError(issues, step, location, "SetFieldValue action requires a target field.", eventName, actionSequence, stepIndex); + break; + case DbActionKind.RunActionSequence: + ValidateRunActionSequence(step, location, eventName, actionSequence, stepIndex, sequenceNames, issues); + break; + case DbActionKind.OpenForm: + RequireCapability(capabilities.OpenForm, issues, step, location, "OpenForm", eventName, actionSequence, stepIndex); + ValidateOpenForm(step, location, eventName, actionSequence, stepIndex, availableForms, issues); + break; + case DbActionKind.CloseForm: + RequireCapability(capabilities.CloseForm, issues, step, location, "CloseForm", eventName, actionSequence, stepIndex); + break; + case DbActionKind.ApplyFilter: + RequireCapability(capabilities.ApplyFilter, issues, step, location, "ApplyFilter", eventName, actionSequence, stepIndex); + ValidateApplyFilter(step, location, eventName, actionSequence, stepIndex, controlIds, schema, issues); + break; + case DbActionKind.ClearFilter: + RequireCapability(capabilities.ClearFilter, issues, step, location, "ClearFilter", eventName, actionSequence, stepIndex); + ValidateOptionalControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + break; + case DbActionKind.RunSql: + RequireCapability(capabilities.RunSql, issues, step, location, "RunSql", eventName, actionSequence, stepIndex); + if (string.IsNullOrWhiteSpace(ReadText(step.Value) ?? ReadText(step.Target) ?? ReadArgumentText(step.Arguments, "sql", "name"))) + AddError(issues, step, location, "RunSql action requires SQL text or a named SQL operation.", eventName, actionSequence, stepIndex); + break; + case DbActionKind.RunProcedure: + RequireCapability(capabilities.RunProcedure, issues, step, location, "RunProcedure", eventName, actionSequence, stepIndex); + ValidateRunProcedure(step, location, eventName, actionSequence, stepIndex, availableProcedures, issues); + break; + case DbActionKind.SetControlProperty: + RequireCapability(capabilities.SetControlProperty, issues, step, location, "SetControlProperty", eventName, actionSequence, stepIndex); + ValidateControlProperty(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + break; + case DbActionKind.SetControlVisibility: + case DbActionKind.SetControlEnabled: + case DbActionKind.SetControlReadOnly: + RequireCapability(capabilities.SetControlProperty, issues, step, location, step.Kind.ToString(), eventName, actionSequence, stepIndex); + ValidateRequiredControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + break; + case DbActionKind.NewRecord: + case DbActionKind.SaveRecord: + case DbActionKind.DeleteRecord: + case DbActionKind.RefreshRecords: + case DbActionKind.PreviousRecord: + case DbActionKind.NextRecord: + case DbActionKind.GoToRecord: + RequireCapability(capabilities.RecordActions, issues, step, location, step.Kind.ToString(), eventName, actionSequence, stepIndex); + break; + } + } + + private static void ValidateRunActionSequence( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + Dictionary sequenceNames, + List issues) + { + string? target = ReadSequenceName(step); + if (string.IsNullOrWhiteSpace(target)) + { + AddError(issues, step, location, "RunActionSequence action requires a sequence name.", eventName, actionSequence, stepIndex); + return; + } + + if (!sequenceNames.TryGetValue(target, out int count)) + { + AddError(issues, step, location, $"Unknown form action sequence '{target}'.", eventName, actionSequence, stepIndex, target); + return; + } + + if (count > 1) + AddError(issues, step, location, $"Form action sequence name '{target}' is ambiguous.", eventName, actionSequence, stepIndex, target); + } + + private static void ValidateOpenForm( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet? availableForms, + List issues) + { + string? formName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(step.Arguments, "formName", "form", "name"); + if (string.IsNullOrWhiteSpace(formName)) + { + AddError(issues, step, location, "OpenForm action requires a target form name.", eventName, actionSequence, stepIndex); + return; + } + + if (availableForms is not null && !availableForms.Contains(formName)) + AddError(issues, step, location, $"OpenForm target '{formName}' was not found.", eventName, actionSequence, stepIndex, formName); + } + + private static void ValidateApplyFilter( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + FormTableDefinition? schema, + List issues) + { + ValidateOptionalControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + string? filter = ReadText(step.Value) ?? ReadArgumentText(step.Arguments, "filter", "where"); + string? target = ReadText(step.Target) ?? ReadArgumentText(step.Arguments, "target"); + bool targetsForm = string.IsNullOrWhiteSpace(target) || + string.Equals(target, "form", StringComparison.OrdinalIgnoreCase); + FormTableDefinition? filterSchema = targetsForm ? schema : null; + if (string.IsNullOrWhiteSpace(filter)) + AddError(issues, step, location, "ApplyFilter action requires a filter expression.", eventName, actionSequence, stepIndex); + else if (!FormFilterExpression.TryParse(filter, filterSchema, out _, out string? filterError)) + AddError(issues, step, location, $"ApplyFilter expression '{filter}' is malformed: {filterError}", eventName, actionSequence, stepIndex); + } + + private static void ValidateRunProcedure( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet? availableProcedures, + List issues) + { + string? procedureName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(step.Arguments, "procedureName", "procedure", "name"); + if (string.IsNullOrWhiteSpace(procedureName)) + { + AddError(issues, step, location, "RunProcedure action requires a procedure name.", eventName, actionSequence, stepIndex); + return; + } + + if (availableProcedures is not null && !availableProcedures.Contains(procedureName)) + AddError(issues, step, location, $"Procedure '{procedureName}' was not found.", eventName, actionSequence, stepIndex, procedureName); + } + + private static void ValidateControlProperty( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + List issues) + { + ValidateRequiredControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + string? property = ReadArgumentText(step.Arguments, "property", "propertyName"); + if (string.IsNullOrWhiteSpace(property)) + { + AddError(issues, step, location, "SetControlProperty action requires a property name.", eventName, actionSequence, stepIndex); + return; + } + + if (!s_supportedControlProperties.Contains(property)) + AddError(issues, step, location, $"Control property '{property}' is not supported.", eventName, actionSequence, stepIndex, step.Target); + } + + private static void ValidateRequiredControlTarget( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + List issues) + { + if (string.IsNullOrWhiteSpace(step.Target)) + { + AddError(issues, step, location, $"{step.Kind} action requires a target control id.", eventName, actionSequence, stepIndex); + return; + } + + if (!controlIds.Contains(step.Target)) + AddError(issues, step, location, $"Unknown control '{step.Target}'.", eventName, actionSequence, stepIndex, step.Target); + } + + private static void ValidateOptionalControlTarget( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + List issues) + { + if (string.IsNullOrWhiteSpace(step.Target) || + string.Equals(step.Target, "form", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!controlIds.Contains(step.Target)) + AddError(issues, step, location, $"Unknown control '{step.Target}'.", eventName, actionSequence, stepIndex, step.Target); + } + + private static void ValidateRules( + IReadOnlyList? rules, + HashSet controlIds, + FormTableDefinition? schema, + List issues) + { + foreach (ControlRuleDefinition rule in rules ?? []) + { + string ruleId = string.IsNullOrWhiteSpace(rule.RuleId) ? "unnamed" : rule.RuleId.Trim(); + string location = $"form.rules.{ruleId}"; + if (string.IsNullOrWhiteSpace(rule.RuleId)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + location, + "Control rule requires a rule id.")); + } + + if (string.IsNullOrWhiteSpace(rule.Condition)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + location, + $"Control rule '{ruleId}' requires a condition.")); + } + else if (schema is not null) + { + var values = schema.Fields.ToDictionary( + static field => field.Name, + static _ => (object?)null, + StringComparer.OrdinalIgnoreCase); + if (!FormActionConditionEvaluator.TryEvaluate(rule.Condition, values, null, null, null, out _, out string? conditionError)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + location, + $"Control rule '{ruleId}' condition is malformed: {conditionError}")); + } + } + + if (rule.Effects.Count == 0) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + location, + $"Control rule '{ruleId}' requires at least one effect.")); + } + + for (int i = 0; i < rule.Effects.Count; i++) + { + ControlRuleEffect effect = rule.Effects[i]; + string effectLocation = $"{location}.effects[{i}]"; + if (string.IsNullOrWhiteSpace(effect.ControlId) || !controlIds.Contains(effect.ControlId)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + effectLocation, + $"Control rule '{ruleId}' targets unknown control '{effect.ControlId}'.", + Target: effect.ControlId)); + } + + if (string.IsNullOrWhiteSpace(effect.Property) || !s_supportedControlProperties.Contains(effect.Property)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + effectLocation, + $"Control rule '{ruleId}' uses unsupported property '{effect.Property}'.", + Target: effect.ControlId)); + } + } + } + } + + private static void RequireCapability( + bool capability, + List issues, + DbActionStep step, + string location, + string actionName, + string? eventName, + string? actionSequence, + int stepIndex) + { + if (capability) + return; + + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Warning, + step.Kind, + "admin.forms", + location, + $"{actionName} action requires a rendered form runtime capability.", + Target: step.Target, + EventName: eventName, + ActionSequence: actionSequence, + StepIndex: stepIndex)); + } + + private static void AddError( + List issues, + DbActionStep step, + string location, + string message, + string? eventName, + string? actionSequence, + int stepIndex, + string? target = null) + => issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + step.Kind, + "admin.forms", + location, + message, + Target: target ?? step.Target, + EventName: eventName, + ActionSequence: actionSequence, + StepIndex: stepIndex)); + + private static string? ReadSequenceName(DbActionStep step) + { + if (!string.IsNullOrWhiteSpace(step.SequenceName)) + return step.SequenceName.Trim(); + + if (!string.IsNullOrWhiteSpace(step.Target)) + return step.Target.Trim(); + + return ReadArgumentText(step.Arguments, "sequenceName", "sequence", "name"); + } + + private static string? ReadText(object? value) + => NormalizeValue(value)?.ToString()?.Trim(); + + private static string? ReadArgumentText( + IReadOnlyDictionary? arguments, + params string[] keys) + { + if (arguments is null) + return null; + + foreach (string key in keys) + { + if (arguments.TryGetValue(key, out object? value)) + { + string? text = ReadText(value); + if (!string.IsNullOrWhiteSpace(text)) + return text; + } + } + + return null; + } + + private static object? NormalizeValue(object? value) + => value is JsonElement json ? NormalizeJsonValue(json) : value; + + private static object? NormalizeJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + _ => value.ToString(), + }; + + private static bool HasBalancedBracketsAndQuotes(string filter) + { + int bracketDepth = 0; + bool inString = false; + for (int i = 0; i < filter.Length; i++) + { + char ch = filter[i]; + if (ch == '\'') + { + inString = !inString; + continue; + } + + if (inString) + continue; + + if (ch == '[') + { + bracketDepth++; + continue; + } + + if (ch == ']') + { + bracketDepth--; + if (bracketDepth < 0) + return false; + } + } + + return bracketDepth == 0 && !inString; + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs new file mode 100644 index 00000000..f720d9f2 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs @@ -0,0 +1,883 @@ +using System.Globalization; +using System.Text.Json; +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class FormActionSequenceExecutor +{ + private const int MaxNestedActionSequenceDepth = 8; + + public static async Task ExecuteAsync( + DbActionSequence sequence, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences = null, + Func? setFieldValue = null, + Func? showMessage = null, + Func>? executeBuiltInFormAction = null, + IFormActionRuntime? actionRuntime = null, + DbExtensionPolicy? callbackPolicy = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sequence); + ArgumentNullException.ThrowIfNull(commands); + ArgumentNullException.ThrowIfNull(metadata); + + return await ExecuteCoreAsync( + sequence, + commands, + record, + bindingArguments, + runtimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + actionRuntime ?? NullFormActionRuntime.Instance, + callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy, + ct, + depth: 0); + } + + private static async Task ExecuteCoreAsync( + DbActionSequence sequence, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences, + Func? setFieldValue, + Func? showMessage, + Func>? executeBuiltInFormAction, + IFormActionRuntime actionRuntime, + DbExtensionPolicy callbackPolicy, + CancellationToken ct, + int depth) + { + IReadOnlyList steps = sequence.Steps ?? []; + string? lastMessage = null; + for (int i = 0; i < steps.Count; i++) + { + DbActionStep step = steps[i]; + FormEventDispatchResult result = await ExecuteStepAsync( + sequence, + step, + i, + commands, + callbackPolicy, + record, + bindingArguments, + runtimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + actionRuntime, + ct, + depth); + + if (!result.Succeeded && step.StopOnFailure) + return result; + + if (result.Succeeded && !string.IsNullOrWhiteSpace(result.Message)) + lastMessage = result.Message; + + if (step.Kind == DbActionKind.Stop) + return result; + } + + return FormEventDispatchResult.Success(lastMessage); + } + + private static async Task ExecuteStepAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + DbCommandRegistry commands, + DbExtensionPolicy callbackPolicy, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences, + Func? setFieldValue, + Func? showMessage, + Func>? executeBuiltInFormAction, + IFormActionRuntime actionRuntime, + CancellationToken ct, + int depth) + { + bool diagnosticsEnabled = FormActionDiagnostics.IsInvocationEnabled; + long started = diagnosticsEnabled ? FormActionDiagnostics.GetTimestamp() : 0; + Dictionary? stepMetadata = diagnosticsEnabled + ? BuildStepMetadata(sequence, step, stepIndex, metadata) + : null; + + try + { + FormEventDispatchResult result = await ExecuteStepCoreAsync( + sequence, + step, + stepIndex, + commands, + callbackPolicy, + record, + bindingArguments, + runtimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + actionRuntime, + ct, + depth); + + WriteActionDiagnostic(step, stepMetadata, started, result, canceled: false, exceptionMessage: null); + return result; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + WriteActionDiagnostic( + step, + stepMetadata, + started, + FormEventDispatchResult.Failure($"Form action '{step.Kind}' was canceled."), + canceled: true, + exceptionMessage: null); + throw; + } + catch (Exception ex) + { + FormEventDispatchResult result = FormEventDispatchResult.Failure( + $"Form action '{step.Kind}' failed: {ex.Message}"); + WriteActionDiagnostic(step, stepMetadata, started, result, canceled: false, ex.Message); + return result; + } + } + + private static async Task ExecuteStepCoreAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + DbCommandRegistry commands, + DbExtensionPolicy callbackPolicy, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences, + Func? setFieldValue, + Func? showMessage, + Func>? executeBuiltInFormAction, + IFormActionRuntime actionRuntime, + CancellationToken ct, + int depth) + { + if (!FormActionConditionEvaluator.TryEvaluate( + step.Condition, + record, + bindingArguments, + runtimeArguments, + step.Arguments, + out bool shouldRun, + out string? conditionError)) + { + return FormEventDispatchResult.Failure( + $"Form action '{step.Kind}' condition failed: {conditionError}"); + } + + if (!shouldRun) + return FormEventDispatchResult.Success(); + + return step.Kind switch + { + DbActionKind.RunCommand => await RunCommandAsync(sequence, step, stepIndex, commands, callbackPolicy, record, bindingArguments, runtimeArguments, metadata, ct), + DbActionKind.SetFieldValue => await SetFieldValueAsync(step, record, setFieldValue), + DbActionKind.ShowMessage => await ShowMessageAsync(step, showMessage), + DbActionKind.Stop => FormEventDispatchResult.Success(ReadMessage(step)), + DbActionKind.RunActionSequence => await RunActionSequenceAsync( + step, + commands, + record, + bindingArguments, + runtimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + actionRuntime, + callbackPolicy, + ct, + depth), + DbActionKind.OpenForm => await OpenFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.CloseForm => await CloseFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.ApplyFilter => await ApplyFilterAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.ClearFilter => await ClearFilterAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.RunSql => await RunSqlAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.RunProcedure => await RunProcedureAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlProperty => await SetControlPropertyAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlVisibility => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "visible", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlEnabled => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "enabled", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlReadOnly => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "readOnly", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.NewRecord or + DbActionKind.SaveRecord or + DbActionKind.DeleteRecord or + DbActionKind.RefreshRecords or + DbActionKind.PreviousRecord or + DbActionKind.NextRecord or + DbActionKind.GoToRecord => await ExecuteRecordActionAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + _ => FormEventDispatchResult.Failure($"Unsupported form action kind '{step.Kind}'."), + }; + } + + private static void WriteActionDiagnostic( + DbActionStep step, + IReadOnlyDictionary? metadata, + long started, + FormEventDispatchResult result, + bool canceled, + string? exceptionMessage) + { + if (metadata is null) + return; + + FormActionDiagnostics.WriteInvocation( + step.Kind, + step.Target, + metadata, + FormActionDiagnostics.GetElapsedTime(started), + result.Succeeded, + canceled, + result.Message, + exceptionMessage); + } + + private static Task ExecuteRecordActionAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + return actionRuntime.ExecuteRecordActionAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + step, + ct); + } + + private static Task OpenFormAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? formName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(arguments, "formName", "form", "name"); + if (string.IsNullOrWhiteSpace(formName)) + return Task.FromResult(FormEventDispatchResult.Failure("OpenForm action requires a target form name.")); + + return actionRuntime.OpenFormAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + formName, + arguments, + ct); + } + + private static Task CloseFormAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? formName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(arguments, "formName", "form", "name"); + + return actionRuntime.CloseFormAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + formName, + ct); + } + + private static Task ApplyFilterAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string target = ReadText(step.Target) + ?? ReadArgumentText(arguments, "target") + ?? "form"; + string? filter = ReadText(step.Value) + ?? ReadArgumentText(arguments, "filter", "where"); + if (string.IsNullOrWhiteSpace(filter)) + return Task.FromResult(FormEventDispatchResult.Failure("ApplyFilter action requires a filter expression.")); + + return actionRuntime.ApplyFilterAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + target, + filter, + arguments, + ct); + } + + private static Task ClearFilterAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string target = ReadText(step.Target) + ?? ReadArgumentText(arguments, "target") + ?? "form"; + + return actionRuntime.ClearFilterAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + target, + ct); + } + + private static Task RunSqlAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? sqlOrName = ReadText(step.Value) + ?? ReadText(step.Target) + ?? ReadArgumentText(arguments, "sql", "name"); + if (string.IsNullOrWhiteSpace(sqlOrName)) + return Task.FromResult(FormEventDispatchResult.Failure("RunSql action requires SQL text or a named SQL operation.")); + + return actionRuntime.RunSqlAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + sqlOrName, + arguments, + ct); + } + + private static Task RunProcedureAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? procedureName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(arguments, "procedureName", "procedure", "name"); + if (string.IsNullOrWhiteSpace(procedureName)) + return Task.FromResult(FormEventDispatchResult.Failure("RunProcedure action requires a procedure name.")); + + return actionRuntime.RunProcedureAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + procedureName, + arguments, + ct); + } + + private static Task SetControlPropertyAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? propertyName = ReadArgumentText(arguments, "property", "propertyName"); + return SetControlPropertyCoreAsync( + sequence, + step, + stepIndex, + propertyName, + ReadValue(step, arguments), + record, + bindingArguments, + runtimeArguments, + metadata, + actionRuntime, + executeBuiltInFormAction, + ct); + } + + private static Task SetSpecificControlPropertyAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + string propertyName, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + return SetControlPropertyCoreAsync( + sequence, + step, + stepIndex, + propertyName, + ReadValue(step, arguments), + record, + bindingArguments, + runtimeArguments, + metadata, + actionRuntime, + executeBuiltInFormAction, + ct); + } + + private static Task SetControlPropertyCoreAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + string? propertyName, + object? value, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + string? controlId = ReadText(step.Target); + if (string.IsNullOrWhiteSpace(controlId)) + return Task.FromResult(FormEventDispatchResult.Failure($"{step.Kind} action requires a target control id.")); + + if (string.IsNullOrWhiteSpace(propertyName)) + return Task.FromResult(FormEventDispatchResult.Failure("SetControlProperty action requires a property name.")); + + return actionRuntime.SetControlPropertyAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + controlId, + propertyName, + value, + ct); + } + + private static async Task RunCommandAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + DbCommandRegistry commands, + DbExtensionPolicy callbackPolicy, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(step.CommandName)) + return FormEventDispatchResult.Failure("RunCommand action requires a command name."); + + if (!commands.TryGetCommand(step.CommandName, out DbCommandDefinition definition)) + { + Dictionary missingMetadata = BuildStepMetadata(sequence, step, stepIndex, metadata); + string message = $"Unknown form command '{step.CommandName}' for action sequence."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(step.CommandName, missingMetadata, message); + return FormEventDispatchResult.Failure(message); + } + + Dictionary arguments = DbCommandArguments.FromObjectDictionaries( + record, + bindingArguments, + runtimeArguments, + step.Arguments); + Dictionary stepMetadata = BuildStepMetadata(sequence, step, stepIndex, metadata); + + try + { + DbCommandResult result = await definition.InvokeAsync( + arguments, + stepMetadata, + callbackPolicy, + DbExtensionHostMode.Embedded, + ct); + if (result.Succeeded) + return FormEventDispatchResult.Success(result.Message); + + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Action command '{definition.Name}' failed." + : result.Message; + return FormEventDispatchResult.Failure(message); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return FormEventDispatchResult.Failure( + $"Action command '{definition.Name}' failed: {ex.Message}"); + } + } + + private static async Task RunActionSequenceAsync( + DbActionStep step, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences, + Func? setFieldValue, + Func? showMessage, + Func>? executeBuiltInFormAction, + IFormActionRuntime actionRuntime, + DbExtensionPolicy callbackPolicy, + CancellationToken ct, + int depth) + { + string? sequenceName = ReadSequenceName(step); + if (string.IsNullOrWhiteSpace(sequenceName)) + return FormEventDispatchResult.Failure("RunActionSequence action requires a sequence name."); + + if (depth >= MaxNestedActionSequenceDepth) + return FormEventDispatchResult.Failure( + $"Action sequence nesting limit exceeded while running '{sequenceName}'."); + + IReadOnlyList matches = (reusableSequences ?? []) + .Where(sequence => string.Equals(sequence.Name, sequenceName, StringComparison.OrdinalIgnoreCase)) + .Take(2) + .ToList(); + + if (matches.Count == 0) + return FormEventDispatchResult.Failure($"Unknown form action sequence '{sequenceName}'."); + + if (matches.Count > 1) + return FormEventDispatchResult.Failure($"Form action sequence name '{sequenceName}' is ambiguous."); + + IReadOnlyDictionary? nestedRuntimeArguments = + MergeRuntimeArguments(runtimeArguments, step.Arguments); + + return await ExecuteCoreAsync( + matches[0], + commands, + record, + bindingArguments, + nestedRuntimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + actionRuntime, + callbackPolicy, + ct, + depth + 1); + } + + private static async Task SetFieldValueAsync( + DbActionStep step, + IReadOnlyDictionary? record, + Func? setFieldValue) + { + if (string.IsNullOrWhiteSpace(step.Target)) + return FormEventDispatchResult.Failure("SetFieldValue action requires a target field."); + + object? value = step.Value is null && step.Arguments?.TryGetValue("value", out object? argumentValue) == true + ? NormalizeValue(argumentValue) + : NormalizeValue(step.Value); + if (setFieldValue is not null) + { + await setFieldValue(step.Target, value); + return FormEventDispatchResult.Success(); + } + + if (record is IDictionary mutableRecord) + { + string key = mutableRecord.Keys.FirstOrDefault(candidate => string.Equals(candidate, step.Target, StringComparison.OrdinalIgnoreCase)) + ?? step.Target; + mutableRecord[key] = value; + return FormEventDispatchResult.Success(); + } + + return FormEventDispatchResult.Failure( + $"SetFieldValue action could not update field '{step.Target}' because the current record is read-only."); + } + + private static async Task ShowMessageAsync( + DbActionStep step, + Func? showMessage) + { + string message = ReadMessage(step) ?? string.Empty; + if (string.IsNullOrWhiteSpace(message)) + return FormEventDispatchResult.Failure("ShowMessage action requires a message."); + + if (showMessage is not null) + await showMessage(message); + + return FormEventDispatchResult.Success(message); + } + + private static Dictionary BuildStepMetadata( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary metadata) + { + var result = new Dictionary(metadata, StringComparer.OrdinalIgnoreCase) + { + ["actionKind"] = step.Kind.ToString(), + ["actionStep"] = stepIndex.ToString(CultureInfo.InvariantCulture), + }; + + if (!string.IsNullOrWhiteSpace(sequence.Name)) + result["actionSequence"] = sequence.Name; + + if (!string.IsNullOrWhiteSpace(step.Target)) + result["actionTarget"] = step.Target; + + if (!string.IsNullOrWhiteSpace(step.Condition)) + result["actionCondition"] = step.Condition; + + string? sequenceName = ReadSequenceName(step); + if (!string.IsNullOrWhiteSpace(sequenceName)) + result["actionSequenceTarget"] = sequenceName; + + return result; + } + + private static FormActionRuntimeContext BuildRuntimeContext( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata) + { + Dictionary stepMetadata = BuildStepMetadata(sequence, step, stepIndex, metadata); + return new FormActionRuntimeContext( + ReadMetadata(stepMetadata, "formId"), + ReadMetadata(stepMetadata, "formName"), + ReadMetadata(stepMetadata, "tableName"), + ReadMetadata(stepMetadata, "event"), + string.IsNullOrWhiteSpace(sequence.Name) ? null : sequence.Name, + stepIndex, + record, + bindingArguments, + runtimeArguments, + NormalizeArguments(step.Arguments), + stepMetadata); + } + + private static string? ReadMetadata(IReadOnlyDictionary metadata, string key) + => metadata.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value) + ? value + : null; + + private static string? ReadSequenceName(DbActionStep step) + { + if (!string.IsNullOrWhiteSpace(step.SequenceName)) + return step.SequenceName.Trim(); + + if (!string.IsNullOrWhiteSpace(step.Target)) + return step.Target.Trim(); + + if (step.Arguments is null) + return null; + + foreach (string key in new[] { "sequenceName", "sequence", "name" }) + { + if (step.Arguments.TryGetValue(key, out object? value)) + return NormalizeValue(value)?.ToString(); + } + + return null; + } + + private static IReadOnlyDictionary? MergeRuntimeArguments( + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary? stepArguments) + { + if (stepArguments is null || stepArguments.Count == 0) + return runtimeArguments; + + var merged = runtimeArguments is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(runtimeArguments, StringComparer.OrdinalIgnoreCase); + + foreach ((string key, object? value) in stepArguments) + merged[key] = NormalizeValue(value); + + return merged; + } + + private static IReadOnlyDictionary NormalizeArguments( + IReadOnlyDictionary? arguments) + { + if (arguments is null || arguments.Count == 0) + return EmptyObjectDictionary.Instance; + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((string key, object? value) in arguments) + { + if (!string.IsNullOrWhiteSpace(key)) + result[key] = NormalizeValue(value); + } + + return result; + } + + private static object? ReadValue(DbActionStep step, IReadOnlyDictionary arguments) + => step.Value is null && arguments.TryGetValue("value", out object? argumentValue) + ? argumentValue + : NormalizeValue(step.Value); + + private static string? ReadText(object? value) + => NormalizeValue(value)?.ToString()?.Trim(); + + private static string? ReadArgumentText( + IReadOnlyDictionary arguments, + params string[] keys) + { + foreach (string key in keys) + { + if (arguments.TryGetValue(key, out object? value)) + { + string? text = ReadText(value); + if (!string.IsNullOrWhiteSpace(text)) + return text; + } + } + + return null; + } + + private static string? ReadMessage(DbActionStep step) + { + if (!string.IsNullOrWhiteSpace(step.Message)) + return step.Message; + + if (step.Value is string text) + return text; + + if (step.Value is JsonElement { ValueKind: JsonValueKind.String } json) + return json.GetString(); + + return null; + } + + private static object? NormalizeValue(object? value) + { + return value switch + { + JsonElement json => NormalizeJsonValue(json), + _ => value, + }; + } + + private static object? NormalizeJsonValue(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary( + static property => property.Name, + static property => NormalizeJsonValue(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => value.EnumerateArray().Select(NormalizeJsonValue).ToArray(), + _ => value.ToString(), + }; + } + + private static class EmptyObjectDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs new file mode 100644 index 00000000..c6bd735c --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs @@ -0,0 +1,131 @@ +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Evaluation; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +public static class FormAutomationMetadata +{ + private const string Surface = "admin.forms"; + private static readonly string[] IgnoredFormulaFunctions = + FormulaEvaluator.BuiltInFunctionNames.Concat(["SUM", "COUNT", "AVG", "MIN", "MAX"]).ToArray(); + private static readonly HashSet BuiltInValidationRuleIds = new(StringComparer.OrdinalIgnoreCase) + { + "required", + "maxLength", + "range", + "regex", + "oneOf", + }; + + public static FormDefinition NormalizeForExport(FormDefinition form) + { + ArgumentNullException.ThrowIfNull(form); + + DbAutomationMetadata metadata = Build(form); + return form with { Automation = metadata.IsEmpty ? null : metadata }; + } + + public static DbAutomationMetadata Build(FormDefinition form) + { + ArgumentNullException.ThrowIfNull(form); + + var builder = new DbAutomationMetadataBuilder(); + foreach (FormEventBinding binding in form.EventBindings ?? []) + { + string bindingLocation = $"form.events.{binding.Event}"; + builder.AddCommand(binding.CommandName, Surface, bindingLocation); + AddActionSequence(builder, binding.ActionSequence, bindingLocation); + } + + foreach (DbActionSequence sequence in form.ActionSequences ?? []) + { + string sequenceLocation = string.IsNullOrWhiteSpace(sequence.Name) + ? "form.actionSequences.unnamed" + : $"form.actionSequences.{sequence.Name}"; + AddActionSequence(builder, sequence, sequenceLocation); + } + + AddValidationRules(builder, form.ValidationRules, "form.validationRules"); + + foreach (ControlDefinition control in form.Controls) + { + AddCommandButton(builder, control); + AddComputedFormula(builder, control); + AddValidationRules(builder, control.ValidationOverride?.AddRules, $"controls.{control.ControlId}.validationRules"); + foreach (ControlEventBinding binding in control.EventBindings ?? []) + { + string bindingLocation = $"controls.{control.ControlId}.events.{binding.Event}"; + builder.AddCommand(binding.CommandName, Surface, bindingLocation); + AddActionSequence(builder, binding.ActionSequence, bindingLocation); + } + } + + return builder.Build(); + } + + private static void AddCommandButton(DbAutomationMetadataBuilder builder, ControlDefinition control) + { + if (!string.Equals(control.ControlType, "commandButton", StringComparison.OrdinalIgnoreCase)) + return; + + if (control.Props.Values.TryGetValue("commandName", out object? commandName)) + builder.AddCommand(commandName?.ToString(), Surface, $"controls.{control.ControlId}.commandButton.click"); + } + + private static void AddComputedFormula(DbAutomationMetadataBuilder builder, ControlDefinition control) + { + if (!string.Equals(control.ControlType, "computed", StringComparison.OrdinalIgnoreCase)) + return; + + if (!control.Props.Values.TryGetValue("formula", out object? formula) || formula is null) + return; + + AddScalarFunctions(builder, formula.ToString(), $"controls.{control.ControlId}.formula"); + } + + private static void AddActionSequence( + DbAutomationMetadataBuilder builder, + DbActionSequence? sequence, + string bindingLocation) + { + if (sequence is null) + return; + + string sequenceLocation = string.IsNullOrWhiteSpace(sequence.Name) + ? $"{bindingLocation}.actionSequence" + : $"{bindingLocation}.actionSequence.{sequence.Name}"; + for (int i = 0; i < sequence.Steps.Count; i++) + { + DbActionStep step = sequence.Steps[i]; + if (step.Kind == DbActionKind.RunCommand) + builder.AddCommand(step.CommandName, Surface, $"{sequenceLocation}.steps[{i}]"); + } + } + + private static void AddValidationRules( + DbAutomationMetadataBuilder builder, + IReadOnlyList? rules, + string locationPrefix) + { + foreach (ValidationRule rule in rules ?? []) + { + if (string.IsNullOrWhiteSpace(rule.RuleId) || + BuiltInValidationRuleIds.Contains(rule.RuleId)) + { + continue; + } + + builder.AddValidationRule(rule.RuleId, Surface, $"{locationPrefix}.{rule.RuleId}"); + } + } + + private static void AddScalarFunctions(DbAutomationMetadataBuilder builder, string? expression, string location) + { + foreach (DbAutomationScalarFunctionCall call in + DbAutomationExpressionInspector.FindScalarFunctionCalls(expression, IgnoredFormulaFunctions)) + { + builder.AddScalarFunction(call.Name, call.Arity, Surface, location); + } + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormChoiceResolver.cs b/src/CSharpDB.Admin.Forms/Services/FormChoiceResolver.cs new file mode 100644 index 00000000..e6fd975b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormChoiceResolver.cs @@ -0,0 +1,268 @@ +using System.Globalization; +using System.Text.Json; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +public static class FormChoiceResolver +{ + private static readonly HashSet s_choiceControlTypes = new(StringComparer.OrdinalIgnoreCase) + { + "select", + "lookup", + "comboBox", + "listBox", + "optionGroup", + "radio", + }; + + public static bool IsChoiceControl(ControlDefinition control) + => s_choiceControlTypes.Contains(control.ControlType); + + public static bool UsesLookupChoices(ControlDefinition control) + => IsChoiceControl(control) + && TryGetString(control.Props.Values, "lookupTable", out _) + && TryGetString(control.Props.Values, "valueField", out _) + && TryGetString(control.Props.Values, "displayField", out _); + + public static IReadOnlyList ResolveChoices( + ControlDefinition control, + string? fieldName, + IReadOnlyDictionary>? runtimeChoices, + FormFieldDefinition? fieldDefinition = null) + { + IReadOnlyList staticChoices = ReadOptions(control.Props.Values.TryGetValue("options", out object? options) ? options : null); + if (staticChoices.Count > 0) + return staticChoices; + + if (runtimeChoices is not null && + runtimeChoices.TryGetValue(control.ControlId, out IReadOnlyList? controlChoices) && + controlChoices is not null) + { + return controlChoices; + } + + if (!string.IsNullOrWhiteSpace(fieldName) && + runtimeChoices is not null && + runtimeChoices.TryGetValue(fieldName, out IReadOnlyList? fieldChoices) && + fieldChoices is not null) + { + return fieldChoices; + } + + return fieldDefinition?.Choices is { Count: > 0 } schemaChoices + ? schemaChoices + : []; + } + + public static IReadOnlyList BuildLookupChoices( + IEnumerable> rows, + string valueField, + string displayField, + IReadOnlyList? displayFields = null) + { + string[] effectiveDisplayFields = displayFields is { Count: > 0 } + ? displayFields.Where(field => !string.IsNullOrWhiteSpace(field)).ToArray() + : [displayField]; + + return rows + .Select(row => new EnumChoice( + LookupField(row, valueField), + BuildDisplayLabel(row, effectiveDisplayFields))) + .Where(choice => !string.IsNullOrWhiteSpace(choice.Value)) + .ToArray(); + } + + public static IReadOnlyList ReadOptions(object? value) + { + value = NormalizeJsonValue(value); + if (value is null) + return []; + + if (value is JsonElement json) + return ReadOptionsFromJson(json); + + if (value is IEnumerable enumChoices) + return enumChoices.ToArray(); + + if (value is IEnumerable items) + { + return items + .Select(ReadOption) + .Where(choice => choice is not null) + .Select(choice => choice!) + .ToArray(); + } + + return []; + } + + public static IReadOnlyList ReadStringList(object? value) + { + value = NormalizeJsonValue(value); + if (value is null) + return []; + + if (value is JsonElement json && json.ValueKind == JsonValueKind.Array) + return json.EnumerateArray() + .Select(element => element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString()) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Select(text => text!) + .ToArray(); + + if (value is IEnumerable items) + return items + .Select(item => NormalizeJsonValue(item)?.ToString()) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Select(text => text!) + .ToArray(); + + string? single = value.ToString(); + return string.IsNullOrWhiteSpace(single) + ? [] + : single.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + public static int ReadInt(IReadOnlyDictionary props, string key, int fallback) + { + if (!props.TryGetValue(key, out object? value) || value is null) + return fallback; + + value = NormalizeJsonValue(value); + return value switch + { + int i => i, + long l => checked((int)l), + double d => checked((int)d), + decimal m => checked((int)m), + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetInt32(out int i) => i, + _ when int.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) => parsed, + _ => fallback, + }; + } + + public static bool TryGetString(IReadOnlyDictionary props, string key, out string value) + { + value = string.Empty; + if (!props.TryGetValue(key, out object? raw) || raw is null) + return false; + + raw = NormalizeJsonValue(raw); + value = raw?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } + + private static EnumChoice? ReadOption(object? value) + { + value = NormalizeJsonValue(value); + if (value is null) + return null; + + if (value is JsonElement json) + return ReadOptionFromJson(json); + + if (value is IReadOnlyDictionary readOnly) + return ReadOptionFromDictionary(readOnly); + + if (value is IDictionary dictionary) + return ReadOptionFromDictionary(dictionary.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase)); + + string? scalar = value.ToString(); + return string.IsNullOrWhiteSpace(scalar) + ? null + : new EnumChoice(scalar, scalar); + } + + private static EnumChoice? ReadOptionFromDictionary(IReadOnlyDictionary dictionary) + { + string optionValue = ReadDictionaryText(dictionary, "value"); + if (string.IsNullOrWhiteSpace(optionValue)) + return null; + + string label = ReadDictionaryText(dictionary, "label"); + return new EnumChoice(optionValue, string.IsNullOrWhiteSpace(label) ? optionValue : label); + } + + private static string ReadDictionaryText(IReadOnlyDictionary dictionary, string key) + { + if (!dictionary.TryGetValue(key, out object? value) || value is null) + return string.Empty; + + return NormalizeJsonValue(value)?.ToString() ?? string.Empty; + } + + private static IReadOnlyList ReadOptionsFromJson(JsonElement json) + { + if (json.ValueKind != JsonValueKind.Array) + return []; + + return json.EnumerateArray() + .Select(ReadOptionFromJson) + .Where(choice => choice is not null) + .Select(choice => choice!) + .ToArray(); + } + + private static EnumChoice? ReadOptionFromJson(JsonElement json) + { + if (json.ValueKind == JsonValueKind.Object) + { + string optionValue = json.TryGetProperty("value", out JsonElement valueElement) + ? ReadJsonText(valueElement) + : string.Empty; + if (string.IsNullOrWhiteSpace(optionValue)) + return null; + + string label = json.TryGetProperty("label", out JsonElement labelElement) + ? ReadJsonText(labelElement) + : optionValue; + return new EnumChoice(optionValue, string.IsNullOrWhiteSpace(label) ? optionValue : label); + } + + string scalar = ReadJsonText(json); + return string.IsNullOrWhiteSpace(scalar) + ? null + : new EnumChoice(scalar, scalar); + } + + private static string ReadJsonText(JsonElement value) + => value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.ToString(); + + private static string LookupField(IReadOnlyDictionary row, string fieldName) + { + if (row.TryGetValue(fieldName, out object? value) && value is not null) + return value.ToString() ?? string.Empty; + + string? actualKey = row.Keys.FirstOrDefault(candidate => string.Equals(candidate, fieldName, StringComparison.OrdinalIgnoreCase)); + return actualKey is not null && row.TryGetValue(actualKey, out value) && value is not null + ? value.ToString() ?? string.Empty + : string.Empty; + } + + private static string BuildDisplayLabel(IReadOnlyDictionary row, IReadOnlyList displayFields) + { + string label = string.Join(" - ", displayFields.Select(field => LookupField(row, field)).Where(value => value.Length > 0)); + return label.Length == 0 && displayFields.Count > 0 + ? LookupField(row, displayFields[0]) + : label; + } + + private static object? NormalizeJsonValue(object? value) + => value is JsonElement json ? NormalizeJsonElement(json) : value; + + private static object? NormalizeJsonElement(JsonElement json) + { + return json.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => json.GetString(), + JsonValueKind.Number when json.TryGetInt64(out long integer) => integer, + JsonValueKind.Number => json.GetDouble(), + _ => json, + }; + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs new file mode 100644 index 00000000..979431f3 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs @@ -0,0 +1,88 @@ +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class FormCommandInvocation +{ + public static Dictionary BuildArguments( + IReadOnlyDictionary? record, + IReadOnlyDictionary? configuredArguments) + => DbCommandArguments.FromObjectDictionary(record, configuredArguments); + + public static Dictionary BuildArguments( + IReadOnlyDictionary? record, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary? configuredArguments) + => DbCommandArguments.FromObjectDictionaries(record, runtimeArguments, configuredArguments); + + public static Dictionary BuildMetadata(FormDefinition form) + { + ArgumentNullException.ThrowIfNull(form); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminForms", + ["formId"] = form.FormId, + ["formName"] = form.Name, + ["tableName"] = form.TableName, + ["ownerKind"] = "Form", + ["ownerId"] = form.FormId, + ["ownerName"] = form.Name, + ["correlationId"] = Guid.NewGuid().ToString("N"), + }; + } + + public static Dictionary BuildControlMetadata( + FormDefinition form, + ControlDefinition control, + string eventName) + { + Dictionary metadata = BuildMetadata(form); + metadata["event"] = eventName; + metadata["controlId"] = control.ControlId; + metadata["controlType"] = control.ControlType; + if (!string.IsNullOrWhiteSpace(control.Binding?.FieldName)) + metadata["fieldName"] = control.Binding.FieldName; + + return metadata; + } + + public static IReadOnlyDictionary? ReadArgumentsProperty(object? value) + { + if (value is null) + return null; + + if (value is IReadOnlyDictionary readOnly) + return readOnly; + + if (value is Dictionary dictionary) + return dictionary; + + if (value is System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Object } json) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (System.Text.Json.JsonProperty property in json.EnumerateObject()) + result[property.Name] = ReadJsonValue(property.Value); + return result; + } + + return null; + } + + private static object? ReadJsonValue(System.Text.Json.JsonElement value) + { + return value.ValueKind switch + { + System.Text.Json.JsonValueKind.Null => null, + System.Text.Json.JsonValueKind.True => true, + System.Text.Json.JsonValueKind.False => false, + System.Text.Json.JsonValueKind.Number => value.TryGetInt64(out long longValue) ? longValue : value.GetDouble(), + System.Text.Json.JsonValueKind.String => value.GetString(), + System.Text.Json.JsonValueKind.Object => ReadArgumentsProperty(value), + System.Text.Json.JsonValueKind.Array => value.EnumerateArray().Select(ReadJsonValue).ToArray(), + _ => value.ToString(), + }; + } + +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormControlRegistry.cs b/src/CSharpDB.Admin.Forms/Services/FormControlRegistry.cs new file mode 100644 index 00000000..140fce96 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormControlRegistry.cs @@ -0,0 +1,33 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class FormControlRegistry : IFormControlRegistry +{ + private readonly Dictionary _controls; + private readonly IReadOnlyList _orderedControls; + + internal FormControlRegistry(IEnumerable controls) + { + _orderedControls = controls + .OrderBy(control => control.ToolboxGroupOrder) + .ThenBy(control => control.ToolboxOrder) + .ThenBy(control => control.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + _controls = _orderedControls.ToDictionary( + control => control.ControlType, + control => control, + StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList Controls => _orderedControls; + + public bool TryGetControl(string controlType, out FormControlDescriptor descriptor) + => _controls.TryGetValue(controlType, out descriptor!); + + public IReadOnlyList GetToolboxControls() + => _orderedControls + .Where(control => control.ShowInToolbox) + .ToArray(); +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormControlRegistryBuilder.cs b/src/CSharpDB.Admin.Forms/Services/FormControlRegistryBuilder.cs new file mode 100644 index 00000000..4534042e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormControlRegistryBuilder.cs @@ -0,0 +1,61 @@ +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class FormControlRegistryBuilder +{ + private readonly Dictionary _controls = new(StringComparer.OrdinalIgnoreCase); + + public FormControlRegistryBuilder Add(FormControlDescriptor descriptor) + { + ValidateDescriptor(descriptor); + + if (_controls.TryGetValue(descriptor.ControlType, out FormControlDescriptor? existing)) + { + if (existing.IsBuiltIn && descriptor.ReplaceBuiltInRuntime && descriptor.RuntimeComponentType is not null) + { + _controls[existing.ControlType] = existing with + { + RuntimeComponentType = descriptor.RuntimeComponentType, + ReplaceBuiltInRuntime = true, + }; + return this; + } + + throw new InvalidOperationException($"A form control with type '{descriptor.ControlType}' is already registered."); + } + + _controls.Add(descriptor.ControlType, descriptor); + return this; + } + + internal FormControlRegistry Build() => new(_controls.Values); + + private static void ValidateDescriptor(FormControlDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + if (string.IsNullOrWhiteSpace(descriptor.ControlType)) + throw new ArgumentException("Control type is required.", nameof(descriptor)); + if (string.IsNullOrWhiteSpace(descriptor.DisplayName)) + throw new ArgumentException("Display name is required.", nameof(descriptor)); + if (descriptor.DefaultWidth <= 0) + throw new ArgumentException("Default width must be greater than zero.", nameof(descriptor)); + if (descriptor.DefaultHeight <= 0) + throw new ArgumentException("Default height must be greater than zero.", nameof(descriptor)); + + FormControlDescriptor.ValidateComponentType(descriptor.DesignerPreviewComponentType, nameof(descriptor.DesignerPreviewComponentType)); + FormControlDescriptor.ValidateComponentType(descriptor.RuntimeComponentType, nameof(descriptor.RuntimeComponentType)); + FormControlDescriptor.ValidateComponentType(descriptor.PropertyEditorComponentType, nameof(descriptor.PropertyEditorComponentType)); + + foreach (FormControlPropertyDescriptor property in descriptor.PropertyDescriptors) + { + if (string.IsNullOrWhiteSpace(property.Name)) + throw new ArgumentException("Property descriptor name is required.", nameof(descriptor)); + if (string.IsNullOrWhiteSpace(property.Label)) + throw new ArgumentException("Property descriptor label is required.", nameof(descriptor)); + if (property.Editor == FormControlPropertyEditor.Select && property.Options.Count == 0) + throw new ArgumentException($"Select property '{property.Name}' must define options.", nameof(descriptor)); + } + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormControlRegistryConfiguration.cs b/src/CSharpDB.Admin.Forms/Services/FormControlRegistryConfiguration.cs new file mode 100644 index 00000000..9b8890a2 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormControlRegistryConfiguration.cs @@ -0,0 +1,12 @@ +namespace CSharpDB.Admin.Forms.Services; + +internal interface IFormControlRegistryConfiguration +{ + void Configure(FormControlRegistryBuilder builder); +} + +internal sealed class DelegateFormControlRegistryConfiguration(Action configure) + : IFormControlRegistryConfiguration +{ + public void Configure(FormControlRegistryBuilder builder) => configure(builder); +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormFilterExpression.cs b/src/CSharpDB.Admin.Forms/Services/FormFilterExpression.cs new file mode 100644 index 00000000..5f172949 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormFilterExpression.cs @@ -0,0 +1,623 @@ +using System.Globalization; +using System.Text.Json; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +internal sealed class FormFilterExpression +{ + private readonly Node _root; + + private FormFilterExpression(Node root, IReadOnlyCollection fields, IReadOnlyCollection parameters) + { + _root = root; + Fields = fields; + Parameters = parameters; + } + + public IReadOnlyCollection Fields { get; } + + public IReadOnlyCollection Parameters { get; } + + public bool Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary? parameters = null) + => IsTruthy(_root.Evaluate(record, parameters ?? EmptyObjectDictionary.Instance)); + + public static bool TryParse( + string expression, + FormTableDefinition? table, + out FormFilterExpression? filter, + out string? error) + { + filter = null; + error = null; + + if (string.IsNullOrWhiteSpace(expression)) + { + error = "Filter expression is empty."; + return false; + } + + if (!Tokenizer.TryTokenize(expression, out List tokens, out error)) + return false; + + var parser = new Parser(tokens); + if (!parser.TryParse(out Node? root, out error)) + return false; + + var fields = new HashSet(StringComparer.OrdinalIgnoreCase); + var parameters = new HashSet(StringComparer.OrdinalIgnoreCase); + root!.CollectReferences(fields, parameters); + + if (table is not null) + { + var availableFields = new HashSet( + table.Fields.Select(static field => field.Name), + StringComparer.OrdinalIgnoreCase); + string? missingField = fields.FirstOrDefault(field => !availableFields.Contains(field)); + if (missingField is not null) + { + error = $"Filter references unknown field '{missingField}'."; + return false; + } + } + + filter = new FormFilterExpression(root, fields, parameters); + return true; + } + + private static object? NormalizeValue(object? value) + => value is JsonElement json ? NormalizeJsonValue(json) : value; + + private static object? NormalizeJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + _ => value.ToString(), + }; + + private static bool IsTruthy(object? value) + { + value = NormalizeValue(value); + if (value is null) + return false; + + if (value is bool boolean) + return boolean; + + if (TryConvertDouble(value, out double number)) + return Math.Abs(number) > double.Epsilon; + + return !string.IsNullOrWhiteSpace(Convert.ToString(value, CultureInfo.InvariantCulture)); + } + + private static bool Compare(object? left, object? right, string op) + { + left = NormalizeValue(left); + right = NormalizeValue(right); + + if (left is null || right is null) + { + int nullComparison = left is null && right is null ? 0 : left is null ? -1 : 1; + return ApplyComparison(nullComparison, op); + } + + if (TryConvertDouble(left, out double leftNumber) && + TryConvertDouble(right, out double rightNumber)) + { + return ApplyComparison(leftNumber.CompareTo(rightNumber), op); + } + + if (left is bool leftBool && right is bool rightBool) + return ApplyComparison(leftBool.CompareTo(rightBool), op); + + int comparison = string.Compare( + Convert.ToString(left, CultureInfo.InvariantCulture), + Convert.ToString(right, CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase); + return ApplyComparison(comparison, op); + } + + private static bool ApplyComparison(int comparison, string op) + => op switch + { + "=" or "==" => comparison == 0, + "!=" or "<>" => comparison != 0, + ">" => comparison > 0, + ">=" => comparison >= 0, + "<" => comparison < 0, + "<=" => comparison <= 0, + _ => false, + }; + + private static bool TryConvertDouble(object? value, out double result) + { + value = NormalizeValue(value); + return value switch + { + byte number => Set(number, out result), + short number => Set(number, out result), + int number => Set(number, out result), + long number => Set(number, out result), + float number => Set(number, out result), + double number => Set(number, out result), + decimal number => Set((double)number, out result), + string text => double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out result), + _ => Set(0, out result, success: false), + }; + } + + private static bool Set(double value, out double result, bool success = true) + { + result = value; + return success; + } + + private abstract class Node + { + public abstract object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters); + + public virtual void CollectReferences(HashSet fields, HashSet parameters) + { + } + } + + private sealed class LiteralNode(object? value) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + => value; + } + + private sealed class FieldNode(string name) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + { + if (record.TryGetValue(name, out object? value)) + return value; + + string? actualKey = record.Keys.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)); + return actualKey is null ? null : record[actualKey]; + } + + public override void CollectReferences(HashSet fields, HashSet parameters) + => fields.Add(name); + } + + private sealed class ParameterNode(string name) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + { + if (parameters.TryGetValue(name, out object? value)) + return value; + + string? actualKey = parameters.Keys.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)); + return actualKey is null ? null : parameters[actualKey]; + } + + public override void CollectReferences(HashSet fields, HashSet parameters) + => parameters.Add(name); + } + + private sealed class ComparisonNode(Node left, string op, Node right) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + => Compare(left.Evaluate(record, parameters), right.Evaluate(record, parameters), op); + + public override void CollectReferences(HashSet fields, HashSet parameters) + { + left.CollectReferences(fields, parameters); + right.CollectReferences(fields, parameters); + } + } + + private sealed class LogicalNode(Node left, TokenType op, Node right) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + => op == TokenType.And + ? IsTruthy(left.Evaluate(record, parameters)) && IsTruthy(right.Evaluate(record, parameters)) + : IsTruthy(left.Evaluate(record, parameters)) || IsTruthy(right.Evaluate(record, parameters)); + + public override void CollectReferences(HashSet fields, HashSet parameters) + { + left.CollectReferences(fields, parameters); + right.CollectReferences(fields, parameters); + } + } + + private sealed class Parser(List tokens) + { + private int _position; + + public bool TryParse(out Node? node, out string? error) + { + node = ParseOr(out error); + if (node is null) + return false; + + if (!IsAtEnd) + { + error = $"Unexpected token '{Current.Text}' in filter expression."; + node = null; + return false; + } + + return true; + } + + private Node? ParseOr(out string? error) + { + Node? left = ParseAnd(out error); + if (left is null) + return null; + + while (Match(TokenType.Or)) + { + Node? right = ParseAnd(out error); + if (right is null) + return null; + + left = new LogicalNode(left, TokenType.Or, right); + } + + return left; + } + + private Node? ParseAnd(out string? error) + { + Node? left = ParseComparison(out error); + if (left is null) + return null; + + while (Match(TokenType.And)) + { + Node? right = ParseComparison(out error); + if (right is null) + return null; + + left = new LogicalNode(left, TokenType.And, right); + } + + return left; + } + + private Node? ParseComparison(out string? error) + { + Node? left = ParsePrimary(out error); + if (left is null) + return null; + + if (Current.Type != TokenType.Operator) + return left; + + string op = Current.Text; + Advance(); + Node? right = ParsePrimary(out error); + return right is null ? null : new ComparisonNode(left, op, right); + } + + private Node? ParsePrimary(out string? error) + { + Token token = Current; + switch (token.Type) + { + case TokenType.Field: + Advance(); + error = null; + return new FieldNode(token.Text); + case TokenType.Parameter: + Advance(); + error = null; + return new ParameterNode(token.Text); + case TokenType.String: + case TokenType.Number: + case TokenType.Boolean: + case TokenType.Null: + Advance(); + error = null; + return new LiteralNode(token.Value); + case TokenType.LeftParen: + Advance(); + Node? expression = ParseOr(out error); + if (expression is null) + return null; + if (!Match(TokenType.RightParen)) + { + error = "Filter expression is missing a closing parenthesis."; + return null; + } + + error = null; + return expression; + default: + error = IsAtEnd + ? "Filter expression ended unexpectedly." + : $"Unexpected token '{token.Text}' in filter expression."; + return null; + } + } + + private bool Match(TokenType type) + { + if (Current.Type != type) + return false; + + Advance(); + return true; + } + + private void Advance() + { + if (!IsAtEnd) + _position++; + } + + private bool IsAtEnd => Current.Type == TokenType.End; + + private Token Current => tokens[_position]; + } + + private static class Tokenizer + { + public static bool TryTokenize(string expression, out List tokens, out string? error) + { + tokens = []; + error = null; + + for (int i = 0; i < expression.Length;) + { + char ch = expression[i]; + if (char.IsWhiteSpace(ch)) + { + i++; + continue; + } + + if (ch == '[') + { + int end = expression.IndexOf(']', i + 1); + if (end < 0) + { + error = "Filter expression has an unclosed field reference."; + return false; + } + + string fieldName = expression[(i + 1)..end].Trim(); + if (fieldName.Length == 0) + { + error = "Filter expression contains an empty field reference."; + return false; + } + + tokens.Add(new Token(TokenType.Field, fieldName)); + i = end + 1; + continue; + } + + if (ch == '@') + { + int start = i + 1; + int end = ReadIdentifierEnd(expression, start); + if (end == start) + { + error = "Filter expression contains an empty parameter reference."; + return false; + } + + tokens.Add(new Token(TokenType.Parameter, expression[start..end])); + i = end; + continue; + } + + if (ch == '\'') + { + if (!TryReadString(expression, i, out string? value, out int nextIndex, out error)) + return false; + + tokens.Add(new Token(TokenType.String, value, value)); + i = nextIndex; + continue; + } + + if (ch == '(') + { + tokens.Add(new Token(TokenType.LeftParen, "(")); + i++; + continue; + } + + if (ch == ')') + { + tokens.Add(new Token(TokenType.RightParen, ")")); + i++; + continue; + } + + string? op = ReadOperator(expression, i); + if (op is not null) + { + tokens.Add(new Token(TokenType.Operator, op)); + i += op.Length; + continue; + } + + if (char.IsDigit(ch) || ch == '-') + { + int end = ReadNumberEnd(expression, i); + if (end > i && TryReadNumber(expression[i..end], out object number)) + { + tokens.Add(new Token(TokenType.Number, expression[i..end], number)); + i = end; + continue; + } + } + + if (IsIdentifierStart(ch)) + { + int end = ReadIdentifierEnd(expression, i); + string identifier = expression[i..end]; + if (string.Equals(identifier, "AND", StringComparison.OrdinalIgnoreCase)) + tokens.Add(new Token(TokenType.And, identifier)); + else if (string.Equals(identifier, "OR", StringComparison.OrdinalIgnoreCase)) + tokens.Add(new Token(TokenType.Or, identifier)); + else if (string.Equals(identifier, "NULL", StringComparison.OrdinalIgnoreCase)) + tokens.Add(new Token(TokenType.Null, identifier, null)); + else if (bool.TryParse(identifier, out bool boolean)) + tokens.Add(new Token(TokenType.Boolean, identifier, boolean)); + else + tokens.Add(new Token(TokenType.String, identifier, identifier)); + i = end; + continue; + } + + error = $"Unexpected character '{ch}' in filter expression."; + return false; + } + + tokens.Add(new Token(TokenType.End, string.Empty)); + return true; + } + + private static bool TryReadString( + string expression, + int start, + out string value, + out int nextIndex, + out string? error) + { + var builder = new System.Text.StringBuilder(); + for (int i = start + 1; i < expression.Length; i++) + { + char ch = expression[i]; + if (ch == '\'') + { + if (i + 1 < expression.Length && expression[i + 1] == '\'') + { + builder.Append('\''); + i++; + continue; + } + + value = builder.ToString(); + nextIndex = i + 1; + error = null; + return true; + } + + builder.Append(ch); + } + + value = string.Empty; + nextIndex = expression.Length; + error = "Filter expression has an unclosed string literal."; + return false; + } + + private static string? ReadOperator(string expression, int index) + { + foreach (string op in new[] { ">=", "<=", "==", "!=", "<>", "=", ">", "<" }) + { + if (expression.AsSpan(index).StartsWith(op, StringComparison.Ordinal)) + return op; + } + + return null; + } + + private static int ReadNumberEnd(string expression, int start) + { + int i = start; + if (i < expression.Length && expression[i] == '-') + i++; + + bool hasDigit = false; + while (i < expression.Length && char.IsDigit(expression[i])) + { + hasDigit = true; + i++; + } + + if (i < expression.Length && expression[i] == '.') + { + i++; + while (i < expression.Length && char.IsDigit(expression[i])) + { + hasDigit = true; + i++; + } + } + + return hasDigit ? i : start; + } + + private static bool TryReadNumber(string text, out object value) + { + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer)) + { + value = integer; + return true; + } + + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double real)) + { + value = real; + return true; + } + + value = text; + return false; + } + + private static int ReadIdentifierEnd(string expression, int start) + { + int i = start; + while (i < expression.Length && (char.IsLetterOrDigit(expression[i]) || expression[i] == '_')) + i++; + + return i; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; + } + + private sealed record Token(TokenType Type, string Text, object? Value = null); + + private enum TokenType + { + Field, + Parameter, + String, + Number, + Boolean, + Null, + Operator, + And, + Or, + LeftParen, + RightParen, + End, + } + + private static class EmptyObjectDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormSql.cs b/src/CSharpDB.Admin.Forms/Services/FormSql.cs index 83bd6720..f33cbdee 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormSql.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormSql.cs @@ -27,7 +27,7 @@ public static string FormatLiteral(object? value) long integer => integer.ToString(CultureInfo.InvariantCulture), double real => real.ToString(CultureInfo.InvariantCulture), string text => $"'{text.Replace("'", "''", StringComparison.Ordinal)}'", - byte[] => throw new InvalidOperationException("Blob literals are not supported."), + byte[] blob => $"X'{Convert.ToHexString(blob)}'", _ => $"'{Convert.ToString(normalized, CultureInfo.InvariantCulture)?.Replace("'", "''", StringComparison.Ordinal) ?? string.Empty}'", }; } diff --git a/src/CSharpDB.Admin.Forms/Services/NullFormActionRuntime.cs b/src/CSharpDB.Admin.Forms/Services/NullFormActionRuntime.cs new file mode 100644 index 00000000..7b3ffa9e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/NullFormActionRuntime.cs @@ -0,0 +1,68 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class NullFormActionRuntime : IFormActionRuntime +{ + public static NullFormActionRuntime Instance { get; } = new(); + + public Task ExecuteRecordActionAsync( + FormActionRuntimeContext context, + DbActionStep step, + CancellationToken ct) + => UnsupportedAsync(step.Kind); + + public Task OpenFormAsync( + FormActionRuntimeContext context, + string formName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.OpenForm); + + public Task CloseFormAsync( + FormActionRuntimeContext context, + string? formName, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.CloseForm); + + public Task ApplyFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.ApplyFilter); + + public Task ClearFilterAsync( + FormActionRuntimeContext context, + string target, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.ClearFilter); + + public Task RunSqlAsync( + FormActionRuntimeContext context, + string sqlOrName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.RunSql); + + public Task RunProcedureAsync( + FormActionRuntimeContext context, + string procedureName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.RunProcedure); + + public Task SetControlPropertyAsync( + FormActionRuntimeContext context, + string controlId, + string propertyName, + object? value, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.SetControlProperty); + + private static Task UnsupportedAsync(DbActionKind actionKind) + => Task.FromResult(FormEventDispatchResult.Failure( + $"Form action '{actionKind}' requires a rendered form runtime.")); +} diff --git a/src/CSharpDB.Admin.Forms/Services/NullFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/NullFormEventDispatcher.cs new file mode 100644 index 00000000..6c413c2a --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/NullFormEventDispatcher.cs @@ -0,0 +1,20 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class NullFormEventDispatcher : IFormEventDispatcher +{ + public static NullFormEventDispatcher Instance { get; } = new(); + + private NullFormEventDispatcher() + { + } + + public Task DispatchAsync( + FormDefinition form, + FormEventKind eventKind, + IReadOnlyDictionary? record = null, + CancellationToken ct = default) + => Task.FromResult(FormEventDispatchResult.Success()); +} diff --git a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css index c96e92cd..7782a51d 100644 --- a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css +++ b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css @@ -224,6 +224,27 @@ pointer-events: none; } +.preview-command-button, +.fr-command-button { + width: 100%; + height: 100%; + border: 1px solid #c0c0c0; + border-radius: 4px; + background: #fff; + color: #1a1a1a; + font: inherit; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-command-button:disabled, +.fr-command-button:disabled { + opacity: 0.6; + cursor: default; +} + /* ===== Resize Handles ===== */ .resize-handle { position: absolute; @@ -296,7 +317,8 @@ .pi-field input[type="text"], .pi-field input[type="number"], -.pi-field select { +.pi-field select, +.pi-field textarea { width: 100%; padding: 4px 6px; border: 1px solid #d0d0d0; @@ -322,7 +344,8 @@ .pi-field input[type="text"]:focus, .pi-field input[type="number"]:focus, -.pi-field select:focus { +.pi-field select:focus, +.pi-field textarea:focus { outline: none; border-color: #1a73e8; box-shadow: 0 0 0 2px rgba(26,115,232,0.15); @@ -332,6 +355,167 @@ margin: 4px 0; } +.pi-textarea, +.feb-arguments { + min-height: 68px; + resize: vertical; + font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.pi-field-error, +.feb-error { + color: #d32f2f; + font-size: 11px; + margin-top: 4px; +} + +.pi-help-text { + color: var(--cdb-muted, #64748b); + font-size: 11px; + line-height: 1.35; + margin-top: 4px; +} + +.pi-input-action-row { + display: flex; + gap: 4px; + align-items: stretch; +} + +.pi-input-action-row input { + min-width: 0; +} + +.pi-icon-btn { + flex: 0 0 auto; + min-width: 34px; + padding: 4px 8px; + border: 1px solid #d0d0d0; + border-radius: 3px; + background: #fff; + color: #333; + cursor: pointer; + font-size: 11px; + font-weight: 600; +} + +.pi-icon-btn:hover { + background: #e8e8e8; +} + +.pi-formula-helper { + margin: 6px 0 8px; + padding: 8px; + border: 1px solid #d0d0d0; + border-radius: 4px; + background: #fafafa; +} + +.pi-formula-helper-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + margin-bottom: 8px; +} + +.pi-formula-fields { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} + +.pi-formula-field { + max-width: 100%; + padding: 3px 6px; + border: 1px solid #d0d0d0; + border-radius: 999px; + background: #fff; + color: #333; + cursor: pointer; + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pi-formula-group { + margin-top: 8px; +} + +.pi-formula-group-label { + margin-bottom: 4px; + color: #777; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; +} + +.pi-formula-function { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px; + margin-bottom: 4px; +} + +.pi-formula-function-main, +.pi-formula-example-btn { + border: 1px solid #d0d0d0; + border-radius: 4px; + background: #fff; + color: #333; + cursor: pointer; +} + +.pi-formula-function-main { + min-width: 0; + padding: 6px; + text-align: left; +} + +.pi-formula-function-main strong, +.pi-formula-function-main code, +.pi-formula-function-main span { + display: block; +} + +.pi-formula-function-main strong { + color: #1a73e8; + font-size: 12px; +} + +.pi-formula-function-main code { + margin-top: 2px; + color: #555; + font-size: 10px; + white-space: normal; +} + +.pi-formula-function-main span { + margin-top: 2px; + color: #777; + font-size: 10px; + line-height: 1.3; +} + +.pi-formula-example-btn { + padding: 0 6px; + font-size: 10px; +} + +.pi-formula-function-main:hover, +.pi-formula-example-btn:hover, +.pi-formula-field:hover { + background: #e8f0fe; +} + +.pi-formula-empty { + color: #999; + font-size: 11px; + padding: 6px 0 2px; +} + .pi-readonly { background: #f5f5f5 !important; color: #888; @@ -348,6 +532,20 @@ margin-bottom: 0; } +.pi-anchor-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 8px; +} + +.pi-anchor-grid label { + display: flex; + align-items: center; + gap: 6px; + color: #555; + font-size: 12px; +} + .pi-btn { flex: 1; padding: 4px 8px; @@ -491,6 +689,8 @@ .de-form-area { grid-column: 1; + container-name: form-area; + container-type: inline-size; overflow: auto; padding: 24px; background: #fff; @@ -578,7 +778,43 @@ /* ===== Form Renderer (runtime controls) ===== */ .form-renderer { position: relative; - min-height: 500px; + min-height: var(--fr-canvas-height, 500px); + max-width: 100%; +} + +.form-renderer-fixed { + height: 100%; +} + +.fr-control { + position: absolute; + left: var(--fr-left, auto); + top: var(--fr-top, auto); + right: var(--fr-right, auto); + bottom: var(--fr-bottom, auto); + width: var(--fr-width, auto); + height: var(--fr-height, auto); + min-width: var(--fr-min-width, 0); + min-height: var(--fr-min-height, 0); + box-sizing: border-box; +} + +.form-renderer-elastic { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 12px; + align-items: start; + min-height: 0; +} + +.form-renderer-elastic .fr-control { + position: static; + width: auto; + height: auto; + min-width: var(--fr-min-width, 0); + min-height: var(--fr-elastic-min-height, var(--fr-min-height, 0)); + order: var(--fr-stack-order); + grid-column: span var(--fr-grid-span); } .fr-label { @@ -626,6 +862,192 @@ cursor: pointer; } +@media (min-width: 641px) and (max-width: 900px) { + .form-renderer-elastic { + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 10px; + } + + .form-renderer-elastic .fr-control { + grid-column: span var(--fr-tablet-span); + } + + .fr-hidden-tablet { + display: none !important; + } +} + +@container form-area (min-width: 641px) and (max-width: 900px) { + .form-renderer-elastic { + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 10px; + } + + .form-renderer-elastic .fr-control { + grid-column: span var(--fr-tablet-span); + } + + .fr-hidden-tablet { + display: none !important; + } +} + +@media (max-width: 640px) { + .form-renderer { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + } + + .form-renderer .fr-control, + .form-renderer-elastic .fr-control { + position: static; + left: auto; + top: auto; + right: auto; + bottom: auto; + width: 100%; + height: auto; + min-width: 0; + min-height: 44px; + order: var(--fr-stack-order); + grid-column: 1 / -1; + } + + .form-renderer .fr-control-label { + min-height: auto; + } + + .fr-input, + .fr-command-button { + min-height: 44px; + height: auto; + padding: 10px 12px; + font-size: 16px; + } + + .fr-label { + min-height: 24px; + height: auto; + align-items: flex-end; + } + + .fr-checkbox { + min-height: 44px; + height: auto; + align-items: center; + } + + .fr-checkbox input[type="checkbox"], + .fr-radio input[type="radio"] { + min-width: 20px; + width: 20px; + height: 20px; + } + + .fr-radio-group { + gap: 8px; + } + + .fr-radio { + min-height: 40px; + } + + .fr-textarea { + min-height: 96px; + resize: vertical; + } + + .fr-error-message { + position: static; + margin-top: 4px; + white-space: normal; + } + + .fr-hidden-mobile { + display: none !important; + } +} + +@container form-area (max-width: 640px) { + .form-renderer { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + } + + .form-renderer .fr-control, + .form-renderer-elastic .fr-control { + position: static; + left: auto; + top: auto; + right: auto; + bottom: auto; + width: 100%; + height: auto; + min-width: 0; + min-height: 44px; + order: var(--fr-stack-order); + grid-column: 1 / -1; + } + + .form-renderer .fr-control-label { + min-height: auto; + } + + .fr-input, + .fr-command-button { + min-height: 44px; + height: auto; + padding: 10px 12px; + font-size: 16px; + } + + .fr-label { + min-height: 24px; + height: auto; + align-items: flex-end; + } + + .fr-checkbox { + min-height: 44px; + height: auto; + align-items: center; + } + + .fr-checkbox input[type="checkbox"], + .fr-radio input[type="radio"] { + min-width: 20px; + width: 20px; + height: 20px; + } + + .fr-radio-group { + gap: 8px; + } + + .fr-radio { + min-height: 40px; + } + + .fr-textarea { + min-height: 96px; + resize: vertical; + } + + .fr-error-message { + position: static; + margin-top: 4px; + white-space: normal; + } + + .fr-hidden-mobile { + display: none !important; + } +} + .fr-checkbox input[type="checkbox"] { width: 16px; height: 16px; @@ -650,6 +1072,275 @@ cursor: pointer; } +.fr-listbox { + height: 100%; + padding: 4px; +} + +.fr-option-group { + display: flex; + gap: 6px; + height: 100%; + align-content: flex-start; + overflow: auto; +} + +.fr-option-vertical { + flex-direction: column; +} + +.fr-option-horizontal { + flex-direction: row; + flex-wrap: wrap; +} + +.fr-option { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; +} + +.fr-option-buttons .fr-option { + border: 1px solid var(--fd-border, #c9d2df); + border-radius: 4px; + padding: 4px 8px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); +} + +.fr-option-buttons .fr-option:has(input:checked) { + border-color: var(--fd-accent, #1a73e8); + background: var(--fd-accent-soft, #e8f0fe); + color: var(--fd-accent, #174ea6); +} + +.fr-toggle-button { + width: 100%; + height: 100%; + border: 1px solid var(--fd-border, #c0c0c0); + border-radius: 4px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + font: inherit; + cursor: pointer; +} + +.fr-toggle-button.active { + border-color: var(--fd-accent, #188038); + background: var(--fd-accent-soft, #e6f4ea); + color: var(--fd-accent, #137333); +} + +.fr-toggle-button:disabled { + opacity: 0.6; + cursor: default; +} + +.fr-tab-control { + display: flex; + flex-direction: column; + height: 100%; + min-height: 120px; + border: 1px solid var(--fd-border, #c9d2df); + border-radius: 4px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + overflow: hidden; +} + +.fr-tab-bar { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--fd-border, #c9d2df); + background: var(--fd-bg-tertiary, #f6f8fb); + padding: 4px 4px 0; +} + +.fr-tab-button { + border: 1px solid transparent; + border-bottom: none; + border-radius: 4px 4px 0 0; + background: transparent; + color: var(--fd-text-secondary, #4b5563); + padding: 6px 10px; + font: inherit; + cursor: pointer; +} + +.fr-tab-button.active { + border-color: var(--fd-border, #c9d2df); + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + margin-bottom: -1px; +} + +.fr-tab-page { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px; +} + +.fr-tab-page .form-renderer { + min-height: 100%; +} + +.fr-tab-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 80px; + color: var(--fd-text-muted, #999); + font-size: 12px; + font-style: italic; +} + +.fr-subform { + height: 100%; + min-height: 180px; + border: 1px solid var(--fd-border, #d0d7de); + border-radius: 4px; + overflow: hidden; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); +} + +.fr-subform .data-entry-layout { + height: 100%; +} + +.data-entry-no-record-list { + grid-template-columns: minmax(0, 1fr); +} + +.data-entry-no-record-list .de-toolbar, +.data-entry-no-record-list .de-error, +.data-entry-no-record-list .de-print-header, +.data-entry-no-record-list .de-form-area, +.data-entry-no-record-list .de-loading { + grid-column: 1; +} + +.data-entry-embedded { + height: 100%; + min-height: 0; + background: var(--fd-bg-primary, #fff); +} + +.data-entry-embedded .de-form-area { + margin: 0; + border-radius: 0; + box-shadow: none; + min-height: 0; +} + +.data-entry-no-toolbar { + grid-template-rows: 1fr; +} + +.fr-attachment, +.fr-image-control { + display: flex; + flex-direction: column; + gap: 6px; + height: 100%; + min-height: 60px; + border: 1px solid var(--fd-border, #d0d7de); + border-radius: 4px; + padding: 8px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + box-sizing: border-box; + overflow: hidden; +} + +.fr-attachment-summary { + color: var(--fd-text-secondary, #4b5563); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fr-attachment-actions, +.fr-image-actions { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.fr-attachment-actions input[type="file"], +.fr-image-actions input[type="file"] { + min-width: 0; + flex: 1; +} + +.fr-attachment-clear { + border: 1px solid var(--fd-border, #c9d2df); + border-radius: 4px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + padding: 4px 8px; + font: inherit; + cursor: pointer; +} + +.fr-attachment-clear:disabled { + opacity: 0.5; + cursor: default; +} + +.fr-image-control img { + flex: 1; + width: 100%; + min-height: 0; + border-radius: 3px; + background: var(--fd-bg-tertiary, #f6f8fb); +} + +.fr-image-empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 80px; + color: var(--fd-text-muted, #999); + border: 1px dashed var(--fd-border, #c9d2df); + border-radius: 3px; +} + +.preview-option-group, +.preview-attachment, +.preview-image { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 100%; + border: 1px solid #a0a0a0; + border-radius: 3px; + padding: 6px; + background: #fafafa; + box-sizing: border-box; + color: #555; + font-size: 12px; +} + +.preview-option-group { + flex-direction: column; + align-items: flex-start; +} + +.preview-image { + justify-content: center; + border-style: dashed; +} + /* ===== Feature 4: Editable Form Name ===== */ .toolbar-title-editable { cursor: pointer; @@ -774,6 +1465,116 @@ .de-record-pagination .row-count { text-align: center; } + + .cdg-pagination { + grid-template-columns: 1fr; + justify-items: stretch; + } + + .cdg-page-controls { + flex-wrap: wrap; + } + + .cdg-page-size, + .cdg-row-count { + text-align: center; + } +} + +@media (max-width: 900px) { + .data-entry-layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto auto; + height: auto; + min-height: 100vh; + overflow: auto; + } + + .de-toolbar { + position: sticky; + top: 0; + z-index: 20; + padding: 8px 10px; + } + + .de-toolbar-title { + flex: 1 1 100%; + } + + .de-toolbar-group { + flex: 1 1 100%; + } + + .de-toolbar-input { + flex: 1 1 180px; + width: auto; + } + + .de-form-area { + grid-column: 1; + min-height: 0; + overflow: visible; + } + + .de-record-resize { + display: none; + } + + .de-record-list { + grid-column: 1; + min-height: 220px; + max-height: 42vh; + border-left: none; + border-top: 1px solid var(--fd-border, #d0d0d0); + } +} + +@media (max-width: 640px) { + .de-toolbar { + gap: 8px; + align-items: stretch; + } + + .de-toolbar-sep { + display: none; + } + + .de-btn, + .de-btn-link, + .page-btn { + min-height: 40px; + padding: 8px 12px; + } + + .de-form-area { + margin: 0; + padding: 12px; + border-radius: 0; + box-shadow: none; + } + + .de-toolbar-group { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + } + + .de-toolbar-group .de-toolbar-label { + grid-column: 1 / -1; + } + + .de-toolbar-input { + min-width: 0; + width: 100%; + } + + .de-record-list { + max-height: none; + } + + .de-record-row { + min-height: 44px; + } } /* ===== Feature 2: Marquee Selection ===== */ @@ -838,7 +1639,8 @@ } /* ===== Feature 5: Breakpoint Switcher ===== */ -.breakpoint-switcher { +.breakpoint-switcher, +.layout-mode-switcher { display: flex; gap: 2px; background: #f0f0f0; @@ -846,7 +1648,8 @@ padding: 2px; } -.breakpoint-switcher button { +.breakpoint-switcher button, +.layout-mode-switcher button { padding: 3px 8px; border: none; border-radius: 3px; @@ -857,11 +1660,13 @@ transition: all 0.15s; } -.breakpoint-switcher button:hover { +.breakpoint-switcher button:hover, +.layout-mode-switcher button:hover { background: #e0e0e0; } -.breakpoint-switcher button.bp-active { +.breakpoint-switcher button.bp-active, +.layout-mode-switcher button.bp-active { background: #1a73e8; color: #fff; font-weight: 500; @@ -955,37 +1760,94 @@ color: #333; } -.cdg-btn { - padding: 2px 10px; +.cdg-btn { + padding: 2px 10px; + border: 1px solid #c0c0c0; + border-radius: 3px; + background: #fff; + cursor: pointer; + font-size: 11px; +} + +.cdg-btn:hover { + background: #e8e8e8; +} + +.cdg-btn-add { + background: #1a73e8; + color: #fff; + border-color: #1a73e8; +} + +.cdg-btn-add:hover { + background: #1557b0; +} + +.cdg-btn-add:disabled { + opacity: 0.5; + cursor: default; +} + +.cdg-table-wrapper { + flex: 1; + overflow: auto; +} + +.cdg-pagination { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 5px 8px; + border-top: 1px solid #d0d0d0; + background: #f5f5f5; + min-height: 32px; +} + +.cdg-page-size select { + max-width: 96px; + padding: 2px 6px; border: 1px solid #c0c0c0; border-radius: 3px; background: #fff; - cursor: pointer; font-size: 11px; } -.cdg-btn:hover { - background: #e8e8e8; -} - -.cdg-btn-add { - background: #1a73e8; - color: #fff; - border-color: #1a73e8; +.cdg-page-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 0; } -.cdg-btn-add:hover { - background: #1557b0; +.cdg-page-btn { + min-width: 24px; + height: 24px; + padding: 0 6px; + border: 1px solid #c0c0c0; + border-radius: 3px; + background: #fff; + cursor: pointer; + font-size: 11px; + line-height: 1; } -.cdg-btn-add:disabled { +.cdg-page-btn:disabled { opacity: 0.5; cursor: default; } -.cdg-table-wrapper { - flex: 1; - overflow: auto; +.cdg-page-info, +.cdg-row-count { + font-size: 11px; + color: #666; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.cdg-row-count { + text-align: right; } .cdg-table { @@ -1220,16 +2082,22 @@ } .preview-childtabs-tab { + appearance: none; + background: transparent; + border: 0; + border-right: 1px solid #ddd; padding: 3px 10px; font-size: 10px; font-weight: 500; color: #555; - border-right: 1px solid #ddd; + font-family: inherit; + text-align: left; } -.preview-childtabs-tab:first-child { - color: #1a73e8; - background: #fff; +.preview-childtabs-tab:first-child, +.preview-childtabs-tab.active { + color: var(--fd-accent, #1a73e8); + background: var(--fd-bg-elevated, #fff); font-weight: 600; } @@ -1238,6 +2106,76 @@ overflow: hidden; } +.preview-tab-designer, +.preview-tab-designer * { + pointer-events: auto; +} + +.preview-tab-designer .preview-childtabs-tab { + cursor: pointer; +} + +.preview-tab-designer .preview-childtabs-tab:first-child:not(.active) { + background: transparent !important; + color: var(--fd-text-secondary, #555) !important; + font-weight: 500; +} + +.preview-tab-page { + position: relative; + overflow: auto; + background: var(--fd-bg-elevated, #fff); +} + +.preview-tab-page .preview-childtabs-empty { + min-height: 100%; +} + +.design-tab-child { + position: absolute; + box-sizing: border-box; + min-width: 24px; + min-height: 16px; + border: 1px solid var(--fd-border-light, #a0a0a0); + border-radius: 2px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #333); + cursor: grab; + overflow: visible; +} + +.design-tab-child:hover, +.design-tab-child.selected { + border-color: var(--fd-accent, #1a73e8); +} + +.design-tab-child-content { + width: 100%; + height: 100%; + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + padding: 2px 6px; + box-sizing: border-box; + pointer-events: none; +} + +.design-tab-child-type { + flex: 0 0 auto; + font-size: 10px; + font-weight: 600; + color: var(--fd-accent, #1a73e8); +} + +.design-tab-child-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} + .preview-childtabs-empty { display: flex; align-items: center; @@ -1375,6 +2313,112 @@ background: #d2e3fc; } +/* ===== Form Event Binding Editor ===== */ +.feb-container { + display: flex; + flex-direction: column; + gap: 6px; +} + +.feb-empty { + color: #999; + font-size: 11px; + font-style: italic; +} + +.feb-entry { + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 8px; + background: #fafafa; +} + +.feb-row { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.feb-field { + flex: 1; + margin-bottom: 6px; +} + +.feb-field label { + display: block; + font-size: 11px; + color: #666; + margin-bottom: 2px; +} + +.feb-field input[type="text"], +.feb-field select, +.feb-field textarea { + width: 100%; + padding: 3px 6px; + border: 1px solid #d0d0d0; + border-radius: 3px; + font-size: 11px; + background: #fff; + box-sizing: border-box; +} + +.feb-btn { + padding: 2px 8px; + border: 1px solid #d0d0d0; + border-radius: 3px; + background: #fff; + cursor: pointer; + font-size: 11px; + color: #333; +} + +.feb-btn:hover { + background: #e8e8e8; +} + +.feb-btn-remove { + flex: 0 0 auto; + margin-top: 18px; +} + +.feb-btn-add { + align-self: flex-start; + background: #e8f0fe; + border-color: #c4d9f5; + color: #1a73e8; + font-weight: 500; +} + +.feb-btn-add:hover { + background: #d2e3fc; +} + +.ase-container { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ase-header { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.ase-step { + border: 1px solid #e4e4e4; + border-radius: 4px; + padding: 6px; + background: #fff; +} + +.ase-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + /* ===== Layers Panel ===== */ .layers-panel { flex: 1; @@ -1541,12 +2585,24 @@ background: var(--fd-bg-primary); } +.tce-tab-entry, +.tce-tab-body { + background: var(--fd-bg-secondary); + color: var(--fd-text); +} + +.tce-child-tabs { + border-left-color: var(--fd-border-light); +} + .designer-toolbar, .de-toolbar, .cdg-toolbar, +.cdg-pagination, .ctp-tab-bar, .preview-datagrid-row.header-row, .preview-childtabs-tabbar, +.fr-tab-bar, .toolbox-header, .layers-header, .pi-header, @@ -1565,11 +2621,16 @@ .property-inspector, .de-record-list, .child-datagrid, +.fr-tab-control, +.fr-subform, +.fr-attachment, +.fr-image-control, .tce-tab-entry, .design-canvas, .de-form-area, .preview-datagrid, .preview-childtabs, +.design-tab-child, .child-tab-panel.ctp-nested { border-color: var(--fd-border); } @@ -1584,7 +2645,9 @@ .preview-label, .preview-checkbox, .preview-datagrid-row.header-row, -.preview-childtabs-tab:first-child { +.preview-childtabs-tab:first-child, +.preview-childtabs-tab.active, +.fr-tab-button.active { color: var(--fd-text); } @@ -1603,12 +2666,17 @@ .de-search-status, .page-info, .row-count, +.cdg-page-info, +.cdg-row-count, .pi-empty, .layers-empty, .cdg-empty, .cdg-loading, .ctp-select-prompt, .fr-datagrid-placeholder, +.fr-tab-empty, +.fr-image-empty, +.fr-attachment-summary, .preview-childtabs-empty { color: var(--fd-text-muted); } @@ -1616,40 +2684,128 @@ .designer-toolbar button, .de-btn, .pi-btn, +.pi-icon-btn, +.pi-formula-field, +.pi-formula-function-main, +.pi-formula-example-btn, .cdg-btn, .tce-btn, .breakpoint-switcher button, +.layout-mode-switcher button, .toolbar-source-select, .toolbar-title-input, .de-toolbar-input, .page-size-select select, .page-btn, +.cdg-page-size select, +.cdg-page-btn, .pi-field input[type="text"], .pi-field input[type="number"], .pi-field select, +.pi-field textarea, .de-search-select, .de-search-input, .fr-input, +.fr-toggle-button, +.fr-attachment-clear, +.fr-command-button, +.preview-command-button, .cdg-cell-input, +.cdg-page-size select, .tce-field input[type="text"], -.tce-field select { +.tce-field select, +.feb-field input[type="text"], +.feb-field select, +.feb-field textarea, +.feb-btn { background: var(--fd-bg-elevated); color: var(--fd-text); border-color: var(--fd-border); } +.feb-container, +.ase-container { + color-scheme: dark; +} + +.feb-entry, +.ase-step { + background: var(--fd-bg-tertiary); + color: var(--fd-text); + border-color: var(--fd-border); +} + +.ase-step { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fd-border-light) 35%, transparent); +} + +.feb-empty { + color: var(--fd-text-secondary); +} + +.feb-error { + color: var(--fd-danger); + background: var(--fd-danger-soft); + border: 1px solid color-mix(in srgb, var(--fd-danger) 32%, var(--fd-border)); + border-radius: 4px; + padding: 6px 8px; +} + +.feb-field input[type="checkbox"] { + accent-color: var(--fd-accent); +} + +.tce-field input[type="checkbox"] { + accent-color: var(--fd-accent); +} + +.tce-field select option { + background: var(--fd-bg-elevated); + color: var(--fd-text); +} + +.tce-toggle { + color: var(--fd-text-secondary); +} + +.tce-toggle:hover { + color: var(--fd-text); + background: var(--fd-bg-hover); +} + +.tce-btn-add { + background: var(--fd-accent-soft); + border-color: color-mix(in srgb, var(--fd-accent) 42%, var(--fd-border)); + color: var(--fd-accent); +} + +.tce-btn-add:hover { + background: color-mix(in srgb, var(--fd-accent-soft) 72%, var(--fd-bg-hover)); +} + .designer-toolbar button:hover, .de-btn:hover, .pi-btn:hover, +.pi-icon-btn:hover, +.pi-formula-field:hover, +.pi-formula-function-main:hover, +.pi-formula-example-btn:hover, .cdg-btn:hover, .tce-btn:hover, +.feb-btn:hover, .breakpoint-switcher button:hover, +.layout-mode-switcher button:hover, .page-btn:hover, +.cdg-page-btn:hover, .toolbox-item:hover, .layer-row:hover, .de-record-row:hover, .ctp-tab:hover, -.cdg-cell:hover { +.cdg-cell:hover, +.fr-tab-button:hover, +.fr-attachment-clear:hover, +.fr-toggle-button:hover, +.fr-command-button:hover { background: var(--fd-bg-hover); color: var(--fd-text); } @@ -1661,18 +2817,29 @@ cursor: default; } +.cdg-page-btn:disabled { + opacity: 0.45; + cursor: default; +} + .toolbar-source-select, .toolbar-title-input, .de-toolbar-input, .pi-field input[type="text"], .pi-field input[type="number"], .pi-field select, +.pi-field textarea, .de-search-select, .de-search-input, .fr-input, +.fr-command-button, +.preview-command-button, .cdg-cell-input, .tce-field input[type="text"], -.tce-field select { +.tce-field select, +.feb-field input[type="text"], +.feb-field select, +.feb-field textarea { outline: none; box-shadow: none; } @@ -1683,19 +2850,26 @@ .pi-field input[type="text"]:focus, .pi-field input[type="number"]:focus, .pi-field select:focus, +.pi-field textarea:focus, .de-search-select:focus, .de-search-input:focus, .fr-input:focus, +.fr-command-button:focus, .cdg-cell-input:focus, +.cdg-page-size select:focus, .tce-field input[type="text"]:focus, -.tce-field select:focus { +.tce-field select:focus, +.feb-field input[type="text"]:focus, +.feb-field select:focus, +.feb-field textarea:focus { border-color: var(--fd-accent); box-shadow: 0 0 0 2px var(--fd-accent-soft); } .de-btn-primary, .cdg-btn-add, -.breakpoint-switcher button.bp-active { +.breakpoint-switcher button.bp-active, +.layout-mode-switcher button.bp-active { background: var(--fd-accent); color: #fff; border-color: var(--fd-accent); @@ -1768,8 +2942,13 @@ .de-form-area, .child-datagrid, +.fr-tab-control, +.fr-subform, +.fr-attachment, +.fr-image-control, .preview-datagrid, -.preview-childtabs { +.preview-childtabs, +.design-tab-child { background: var(--fd-bg-elevated); color: var(--fd-text); } @@ -1780,6 +2959,8 @@ .preview-input, .fr-input, +.fr-command-button, +.preview-command-button, .cdg-cell-input, .designer-cell-input, .designer-cell-select { @@ -1791,6 +2972,8 @@ .preview-input::placeholder, .de-toolbar-input::placeholder, .de-search-input::placeholder, +.pi-textarea::placeholder, +.feb-arguments::placeholder, .designer-cell-input::placeholder, .toolbar-title-input::placeholder { color: var(--fd-text-muted); @@ -1811,6 +2994,21 @@ border-color: var(--fd-border); } +.pi-formula-helper { + background: var(--fd-bg-tertiary); + border-color: var(--fd-border); +} + +.pi-formula-group-label, +.pi-formula-function-main code, +.pi-formula-function-main span { + color: var(--fd-text-secondary); +} + +.pi-formula-function-main strong { + color: var(--fd-accent); +} + .pi-id-value { background: color-mix(in srgb, var(--fd-bg-elevated) 88%, var(--fd-accent-soft)); color: var(--fd-text); @@ -1825,8 +3023,10 @@ .de-record-row, .ctp-tab, .preview-childtabs-tab, +.fr-tab-button, .pi-field label, .tce-field label, +.feb-field label, .property-label, .cdg-table thead th, .table-designer-grid thead th { @@ -1840,12 +3040,52 @@ .toolbar-active, .cdg-selected, .preview-childtabs-tab:first-child, +.preview-childtabs-tab.active, +.fr-tab-button.active, +.fr-option-buttons .fr-option:has(input:checked), +.fr-toggle-button.active, .table-designer-grid tbody tr.selected { background: var(--fd-accent-soft) !important; color: var(--fd-accent) !important; border-color: var(--fd-accent); } +.data-entry-layout .child-datagrid .cdg-table tbody tr.cdg-selected, +.data-entry-layout .child-datagrid .cdg-table tbody tr.cdg-editing { + background: color-mix(in srgb, var(--fd-bg-active) 76%, var(--fd-accent) 24%) !important; + color: var(--fd-text) !important; + box-shadow: inset 3px 0 0 var(--fd-accent); +} + +.data-entry-layout .child-datagrid .cdg-table tbody tr.cdg-selected:hover, +.data-entry-layout .child-datagrid .cdg-table tbody tr.cdg-editing:hover { + background: color-mix(in srgb, var(--fd-bg-active) 66%, var(--fd-accent) 34%) !important; +} + +.data-entry-layout .child-datagrid .cdg-table tbody tr.cdg-selected > .cdg-cell, +.data-entry-layout .child-datagrid .cdg-table tbody tr.cdg-editing > .cdg-cell { + background: transparent !important; + color: var(--fd-text) !important; + border-bottom-color: color-mix(in srgb, var(--fd-border-light) 72%, var(--fd-accent) 28%); +} + +.data-entry-layout .child-datagrid .cdg-cell-editing { + background: color-mix(in srgb, var(--fd-bg-tertiary) 78%, var(--fd-accent) 22%) !important; +} + +.data-entry-layout .child-datagrid .cdg-cell-input { + background: var(--fd-bg-secondary) !important; + color: var(--fd-text) !important; + border-color: var(--fd-accent) !important; + caret-color: var(--fd-accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fd-accent) 38%, transparent), 0 0 0 2px var(--fd-accent-soft) !important; +} + +.data-entry-layout .child-datagrid .cdg-cell-input:focus { + background: var(--fd-bg-primary) !important; + color: var(--fd-text) !important; +} + .design-item { border-color: var(--fd-border-light); } @@ -1877,6 +3117,7 @@ .table-designer-preview, .preview-datagrid-row.header-row, .preview-childtabs-tabbar, +.fr-tab-bar, .ctp-tab-bar { background: var(--fd-bg-tertiary); } @@ -1885,8 +3126,13 @@ .de-record-row, .layer-row, .toolbox-group, +.fr-tab-bar, +.fr-tab-button.active, +.fr-attachment-clear, +.fr-image-empty, .pi-section, .tce-tab-entry, +.feb-entry, .table-designer-grid tbody tr, .table-designer-grid td { border-color: var(--fd-border-light); @@ -1903,7 +3149,8 @@ color: var(--fd-text-secondary); } -.breakpoint-switcher { +.breakpoint-switcher, +.layout-mode-switcher { background: var(--fd-bg-tertiary); } @@ -1913,8 +3160,10 @@ .cdg-cell, .de-record-summary, .tce-btn, +.feb-btn, .pi-field input[type="checkbox"], -.tce-field input[type="checkbox"] { +.tce-field input[type="checkbox"], +.feb-field input[type="checkbox"] { color: var(--fd-text); } @@ -2028,7 +3277,8 @@ .cdg-btn, .cdg-btn-add, - .cdg-btn-delete { + .cdg-btn-delete, + .cdg-pagination { display: none !important; } diff --git a/src/CSharpDB.Admin.Reports/CSharpDB.Admin.Reports.csproj b/src/CSharpDB.Admin.Reports/CSharpDB.Admin.Reports.csproj index 37b2988f..56136941 100644 --- a/src/CSharpDB.Admin.Reports/CSharpDB.Admin.Reports.csproj +++ b/src/CSharpDB.Admin.Reports/CSharpDB.Admin.Reports.csproj @@ -10,6 +10,7 @@ + diff --git a/src/CSharpDB.Admin.Reports/Contracts/IReportEventDispatcher.cs b/src/CSharpDB.Admin.Reports/Contracts/IReportEventDispatcher.cs new file mode 100644 index 00000000..96946ac3 --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Contracts/IReportEventDispatcher.cs @@ -0,0 +1,13 @@ +using CSharpDB.Admin.Reports.Models; + +namespace CSharpDB.Admin.Reports.Contracts; + +public interface IReportEventDispatcher +{ + Task DispatchAsync( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind, + IReadOnlyDictionary? runtimeArguments = null, + CancellationToken ct = default); +} diff --git a/src/CSharpDB.Admin.Reports/Contracts/ReportEventDispatchResult.cs b/src/CSharpDB.Admin.Reports/Contracts/ReportEventDispatchResult.cs new file mode 100644 index 00000000..5f5ef8b1 --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Contracts/ReportEventDispatchResult.cs @@ -0,0 +1,12 @@ +namespace CSharpDB.Admin.Reports.Contracts; + +public sealed record ReportEventDispatchResult(bool Succeeded, string? Message = null) +{ + public static ReportEventDispatchResult Success(string? message = null) => new(true, message); + + public static ReportEventDispatchResult Failure(string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return new(false, message); + } +} diff --git a/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs b/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs index b6cbed04..0a3d2aa1 100644 --- a/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs +++ b/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs @@ -1,3 +1,5 @@ +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Reports.Models; public sealed record ReportDefinition( @@ -10,4 +12,6 @@ public sealed record ReportDefinition( IReadOnlyList Groups, IReadOnlyList Sorts, IReadOnlyList Bands, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null, + DbAutomationMetadata? Automation = null); diff --git a/src/CSharpDB.Admin.Reports/Models/ReportEventBinding.cs b/src/CSharpDB.Admin.Reports/Models/ReportEventBinding.cs new file mode 100644 index 00000000..20f0efc0 --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Models/ReportEventBinding.cs @@ -0,0 +1,14 @@ +namespace CSharpDB.Admin.Reports.Models; + +public enum ReportEventKind +{ + OnOpen, + BeforeRender, + AfterRender, +} + +public sealed record ReportEventBinding( + ReportEventKind Event, + string CommandName, + IReadOnlyDictionary? Arguments = null, + bool StopOnFailure = true); diff --git a/src/CSharpDB.Admin.Reports/README.md b/src/CSharpDB.Admin.Reports/README.md index 46cef9a5..feef71ca 100644 --- a/src/CSharpDB.Admin.Reports/README.md +++ b/src/CSharpDB.Admin.Reports/README.md @@ -16,6 +16,8 @@ This project is consumed by `CSharpDB.Admin`. It is not a standalone web host. - grouping, sorting, bands, bound text, calculated text, labels, lines, and box controls - preview pagination and simple expression evaluation +- trusted command-backed preview lifecycle events +- generated automation metadata for import/export host callback requirements ## Main Components @@ -39,11 +41,38 @@ using CSharpDB.Admin.Reports.Services; builder.Services.AddCSharpDbAdminReports(); ``` +Hosts that want Access-style report automation can register trusted commands: + +```csharp +using CSharpDB.Primitives; + +builder.Services.AddCSharpDbAdminReports(commands => +{ + commands.AddAsyncCommand( + "PublishReportRendered", + new DbCommandOptions( + Description: "Publishes report render metrics.", + Timeout: TimeSpan.FromSeconds(5), + IsLongRunning: true), + static async (context, ct) => + { + long pageCount = context.Arguments["pageCount"].AsInteger; + await PublishReportMetricAsync(context.Metadata["reportName"], pageCount, ct); + return DbCommandResult.Success($"Rendered {pageCount} page(s)."); + }); +}); +``` + +Report event dispatch preserves caller cancellation. Command timeouts and other +callback exceptions are returned as failed preview results with the command name +included in the message. + The extension registers: - `IReportRepository` - `IReportSourceProvider` - `IReportGenerator` +- `IReportEventDispatcher` - `IReportPreviewService` ## Core Contracts @@ -70,12 +99,21 @@ public sealed record ReportDefinition( IReadOnlyList Groups, IReadOnlyList Sorts, IReadOnlyList Bands, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null, + DbAutomationMetadata? Automation = null); ``` Report layout is band-based. Each `ReportBandDefinition` owns a list of `ReportControlDefinition` records positioned within that band. +`EventBindings` can reference host-registered commands for `OnOpen`, +`BeforeRender`, and `AfterRender`. Report JSON stores event names, command +names, and optional arguments only; C# command bodies stay in the host process. +`DbReportRepository` regenerates `Automation` on save/load so exported report +JSON lists required trusted commands and scalar functions from event bindings +and calculated text expressions. + ## Build ```powershell diff --git a/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs b/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs index 60f9a3c3..fab89f31 100644 --- a/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs +++ b/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Primitives; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace CSharpDB.Admin.Reports.Services; @@ -7,10 +9,23 @@ public static class AdminReportsServiceCollectionExtensions { public static IServiceCollection AddCSharpDbAdminReports(this IServiceCollection services) { + services.TryAddSingleton(DbCommandRegistry.Empty); + services.TryAddSingleton(DbExtensionPolicies.DefaultHostCallbackPolicy); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } + + public static IServiceCollection AddCSharpDbAdminReports( + this IServiceCollection services, + Action configureCommands) + { + ArgumentNullException.ThrowIfNull(configureCommands); + + services.AddSingleton(DbCommandRegistry.Create(configureCommands)); + return services.AddCSharpDbAdminReports(); + } } diff --git a/src/CSharpDB.Admin.Reports/Services/DbReportRepository.cs b/src/CSharpDB.Admin.Reports/Services/DbReportRepository.cs index 39a3062a..f172a9e3 100644 --- a/src/CSharpDB.Admin.Reports/Services/DbReportRepository.cs +++ b/src/CSharpDB.Admin.Reports/Services/DbReportRepository.cs @@ -288,30 +288,33 @@ SELECT chunk_text private static ReportDefinition DeserializeReportJson(string json) { - return JsonSerializer.Deserialize(json, JsonDefaults.Options) + ReportDefinition report = JsonSerializer.Deserialize(json, JsonDefaults.Options) ?? throw new InvalidOperationException("Stored report definition JSON could not be deserialized."); + return ReportAutomationMetadata.NormalizeForExport(report); } private static ReportDefinition NormalizeForCreate(ReportDefinition report) { ValidateForPersistence(report); - return report with + ReportDefinition stored = report with { ReportId = string.IsNullOrWhiteSpace(report.ReportId) ? Guid.NewGuid().ToString("N") : report.ReportId, Name = string.IsNullOrWhiteSpace(report.Name) ? $"{report.Source.Name} Report" : report.Name.Trim(), DefinitionVersion = 1, }; + return ReportAutomationMetadata.NormalizeForExport(stored); } private static ReportDefinition NormalizeForUpdate(string reportId, int expectedVersion, ReportDefinition report) { ValidateForPersistence(report); - return report with + ReportDefinition stored = report with { ReportId = reportId, Name = string.IsNullOrWhiteSpace(report.Name) ? $"{report.Source.Name} Report" : report.Name.Trim(), DefinitionVersion = expectedVersion + 1, }; + return ReportAutomationMetadata.NormalizeForExport(stored); } private static void ValidateForPersistence(ReportDefinition report) diff --git a/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs b/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs new file mode 100644 index 00000000..483ddc6a --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs @@ -0,0 +1,89 @@ +using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Admin.Reports.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Reports.Services; + +public sealed class DefaultReportEventDispatcher( + DbCommandRegistry commands, + DbExtensionPolicy? callbackPolicy = null) : IReportEventDispatcher +{ + private readonly DbExtensionPolicy _callbackPolicy = callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; + + public async Task DispatchAsync( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind, + IReadOnlyDictionary? runtimeArguments = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(source); + + IReadOnlyList bindings = report.EventBindings ?? []; + foreach (ReportEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) + { + if (string.IsNullOrWhiteSpace(binding.CommandName)) + return ReportEventDispatchResult.Failure($"Report event '{eventKind}' has an empty command name."); + + if (!commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) + { + Dictionary missingMetadata = BuildMetadata(report, source, eventKind); + string message = $"Unknown report command '{binding.CommandName}' for event '{eventKind}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(binding.CommandName, missingMetadata, message); + return ReportEventDispatchResult.Failure(message); + } + + Dictionary arguments = DbCommandArguments.FromObjectDictionary(runtimeArguments, binding.Arguments); + Dictionary metadata = BuildMetadata(report, source, eventKind); + + DbCommandResult result; + try + { + result = await definition.InvokeAsync( + arguments, + metadata, + _callbackPolicy, + DbExtensionHostMode.Embedded, + ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return ReportEventDispatchResult.Failure( + $"Report event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"); + } + + if (!result.Succeeded && binding.StopOnFailure) + { + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Report event '{eventKind}' command '{definition.Name}' failed." + : result.Message; + return ReportEventDispatchResult.Failure(message); + } + } + + return ReportEventDispatchResult.Success(); + } + + private static Dictionary BuildMetadata( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind) + => new(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminReports", + ["reportId"] = report.ReportId, + ["reportName"] = report.Name, + ["ownerKind"] = "Report", + ["ownerId"] = report.ReportId, + ["ownerName"] = report.Name, + ["correlationId"] = Guid.NewGuid().ToString("N"), + ["sourceKind"] = source.Kind.ToString(), + ["sourceName"] = source.Name, + ["event"] = eventKind.ToString(), + }; +} diff --git a/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs b/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs index d7d02027..45b92466 100644 --- a/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs +++ b/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs @@ -1,20 +1,30 @@ using CSharpDB.Admin.Reports.Contracts; using CSharpDB.Admin.Reports.Models; using CSharpDB.Client; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Reports.Services; -public sealed class DefaultReportPreviewService(ICSharpDbClient dbClient, IReportSourceProvider sourceProvider) : IReportPreviewService +public sealed class DefaultReportPreviewService( + ICSharpDbClient dbClient, + IReportSourceProvider sourceProvider, + DbFunctionRegistry? functions = null, + IReportEventDispatcher? reportEvents = null, + DbExtensionPolicy? callbackPolicy = null) : IReportPreviewService { internal const int MaxPreviewRows = 10000; internal const int MaxPreviewPages = 250; private const double PixelsPerInch = 96.0; + private readonly IReportEventDispatcher _reportEvents = reportEvents ?? NullReportEventDispatcher.Instance; + private readonly DbExtensionPolicy _callbackPolicy = callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; public async Task BuildPreviewAsync(ReportDefinition report, CancellationToken ct = default) { ReportSourceDefinition source = await sourceProvider.GetSourceDefinitionAsync(report.Source) ?? throw new InvalidOperationException($"Source '{report.Source.Name}' is no longer available."); + await DispatchReportEventOrThrowAsync(report, source, ReportEventKind.OnOpen, runtimeArguments: null, ct); + IReadOnlyList> loadedRows = source.Kind switch { ReportSourceKind.SavedQuery => SortRowsInMemory( @@ -25,11 +35,24 @@ public async Task BuildPreviewAsync(ReportDefinition report bool rowTruncated = loadedRows.Count > MaxPreviewRows; List> rows = loadedRows.Take(MaxPreviewRows).ToList(); - IReadOnlyList pages = Paginate(report, rows, out bool pageTruncated); + await DispatchReportEventOrThrowAsync( + report, + source, + ReportEventKind.BeforeRender, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["rowCount"] = rows.Count, + ["loadedRowCount"] = loadedRows.Count, + ["rowTruncated"] = rowTruncated, + ["maxPreviewRows"] = MaxPreviewRows, + }, + ct); + + IReadOnlyList pages = Paginate(report, rows, functions ?? DbFunctionRegistry.Empty, _callbackPolicy, out bool pageTruncated); bool hasSchemaDrift = !string.Equals(source.SourceSchemaSignature, report.SourceSchemaSignature, StringComparison.Ordinal); string? warning = BuildWarning(rowTruncated, pageTruncated, hasSchemaDrift); - return new ReportPreviewResult( + var result = new ReportPreviewResult( report, source, pages, @@ -38,6 +61,38 @@ public async Task BuildPreviewAsync(ReportDefinition report hasSchemaDrift, warning, DateTime.UtcNow); + + await DispatchReportEventOrThrowAsync( + report, + source, + ReportEventKind.AfterRender, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["rowCount"] = result.TotalRows, + ["pageCount"] = result.Pages.Count, + ["isTruncated"] = result.IsTruncated, + ["hasSchemaDrift"] = result.HasSchemaDrift, + }, + ct); + + return result; + } + + private async Task DispatchReportEventOrThrowAsync( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind, + IReadOnlyDictionary? runtimeArguments, + CancellationToken ct) + { + ReportEventDispatchResult dispatchResult = await _reportEvents.DispatchAsync(report, source, eventKind, runtimeArguments, ct); + if (!dispatchResult.Succeeded) + { + string message = string.IsNullOrWhiteSpace(dispatchResult.Message) + ? $"Report event '{eventKind}' failed." + : dispatchResult.Message; + throw new InvalidOperationException(message); + } } private static string? BuildWarning(bool rowTruncated, bool pageTruncated, bool hasSchemaDrift) @@ -120,7 +175,12 @@ private static int CompareValues(object? left, object? right) Convert.ToString(normalizedRight, System.Globalization.CultureInfo.InvariantCulture)); } - private static IReadOnlyList Paginate(ReportDefinition report, List> rows, out bool pageTruncated) + private static IReadOnlyList Paginate( + ReportDefinition report, + List> rows, + DbFunctionRegistry functions, + DbExtensionPolicy callbackPolicy, + out bool pageTruncated) { pageTruncated = false; var pages = new List(); @@ -159,7 +219,7 @@ void StartPage() remainingBodyHeight = bodyHeight; if (pageHeader is not null) - currentBands.Add(RenderBand(pageHeader, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc)); + currentBands.Add(RenderBand(pageHeader, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy)); } void FinalizePage() @@ -168,7 +228,7 @@ void FinalizePage() return; if (pageFooter is not null) - currentBands.Add(RenderBand(pageFooter, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc)); + currentBands.Add(RenderBand(pageFooter, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy)); pages.Add(new ReportPreviewPage(pages.Count + 1, currentBands.ToArray())); currentBands = null; @@ -202,7 +262,7 @@ bool TryAddBand(ReportRenderedBand band) return pages; } - if (reportHeader is not null && !TryAddBand(RenderBand(reportHeader, row: rows.FirstOrDefault(), rows, pageNumber: pages.Count + 1, generatedUtc))) + if (reportHeader is not null && !TryAddBand(RenderBand(reportHeader, row: rows.FirstOrDefault(), rows: rows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -230,7 +290,7 @@ bool TryAddBand(ReportRenderedBand band) continue; IReadOnlyList> groupRows = rows.Skip(groupStartIndices[groupIndex]).Take(rowIndex - groupStartIndices[groupIndex]).ToArray(); - if (!TryAddBand(RenderBand(footerBand, row: rows[rowIndex - 1], groupRows, pageNumber: pages.Count + 1, generatedUtc))) + if (!TryAddBand(RenderBand(footerBand, row: rows[rowIndex - 1], rows: groupRows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -253,7 +313,7 @@ bool TryAddBand(ReportRenderedBand band) if (headerBand is null) continue; - if (!TryAddBand(RenderBand(headerBand, row, [row], pageNumber: pages.Count + 1, generatedUtc))) + if (!TryAddBand(RenderBand(headerBand, row: row, rows: [row], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -263,7 +323,7 @@ bool TryAddBand(ReportRenderedBand band) havePreviousRow = true; - if (detailBand is not null && !TryAddBand(RenderBand(detailBand, row, [row], pageNumber: pages.Count + 1, generatedUtc))) + if (detailBand is not null && !TryAddBand(RenderBand(detailBand, row: row, rows: [row], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -283,7 +343,7 @@ bool TryAddBand(ReportRenderedBand band) continue; IReadOnlyList> groupRows = rows.Skip(groupStartIndices[groupIndex]).ToArray(); - if (!TryAddBand(RenderBand(footerBand, row: rows[^1], groupRows, pageNumber: pages.Count + 1, generatedUtc))) + if (!TryAddBand(RenderBand(footerBand, row: rows[^1], rows: groupRows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -292,7 +352,7 @@ bool TryAddBand(ReportRenderedBand band) } if (reportFooter is not null) - TryAddBand(RenderBand(reportFooter, row: rows.LastOrDefault(), rows, pageNumber: pages.Count + 1, generatedUtc)); + TryAddBand(RenderBand(reportFooter, row: rows.LastOrDefault(), rows: rows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy)); FinalizePage(); pageTruncated = truncated; @@ -320,28 +380,49 @@ private static int FindChangedGroupIndex(IReadOnlyList gr private static ReportBandDefinition? FindBand(ReportDefinition report, ReportBandKind kind, string? groupId) => report.Bands.FirstOrDefault(band => band.BandKind == kind && string.Equals(band.GroupId, groupId, StringComparison.Ordinal)); - private static ReportRenderedBand RenderBand(ReportBandDefinition band, IReadOnlyDictionary? row, IReadOnlyList> rows, int pageNumber, DateTime generatedUtc) + private static ReportRenderedBand RenderBand( + ReportBandDefinition band, + IReadOnlyDictionary? row, + IReadOnlyList> rows, + int pageNumber, + DateTime generatedUtc, + DbFunctionRegistry functions, + DbExtensionPolicy callbackPolicy) { ReportRenderedControl[] renderedControls = band.Controls - .Select(control => RenderControl(control, row, rows, pageNumber, generatedUtc)) + .Select(control => RenderControl(control, row, rows, pageNumber, generatedUtc, functions, callbackPolicy)) .ToArray(); return new ReportRenderedBand(band.BandId, band.BandKind, band.GroupId, band.Height, renderedControls); } - private static ReportRenderedControl RenderControl(ReportControlDefinition control, IReadOnlyDictionary? row, IReadOnlyList> rows, int pageNumber, DateTime generatedUtc) + private static ReportRenderedControl RenderControl( + ReportControlDefinition control, + IReadOnlyDictionary? row, + IReadOnlyList> rows, + int pageNumber, + DateTime generatedUtc, + DbFunctionRegistry functions, + DbExtensionPolicy callbackPolicy) { string? text = control.ControlType switch { ReportControlType.Label => LookupProp(control.Props, "text"), ReportControlType.BoundText => ReportSql.FormatDisplayValue(LookupFieldValue(row, control.BoundFieldName), control.FormatString), - ReportControlType.CalculatedText => RenderCalculatedText(control, row, rows, pageNumber, generatedUtc), + ReportControlType.CalculatedText => RenderCalculatedText(control, row, rows, pageNumber, generatedUtc, functions, callbackPolicy), _ => null, }; return new ReportRenderedControl(control.ControlId, control.ControlType, control.Rect, text, control.Props); } - private static string RenderCalculatedText(ReportControlDefinition control, IReadOnlyDictionary? row, IReadOnlyList> rows, int pageNumber, DateTime generatedUtc) + private static string RenderCalculatedText( + ReportControlDefinition control, + IReadOnlyDictionary? row, + IReadOnlyList> rows, + int pageNumber, + DateTime generatedUtc, + DbFunctionRegistry functions, + DbExtensionPolicy callbackPolicy) { string expression = control.Expression?.Trim() ?? string.Empty; string? prefix = LookupProp(control.Props, "prefix"); @@ -352,6 +433,8 @@ private static string RenderCalculatedText(ReportControlDefinition control, IRea "=PrintDate" => ReportSql.FormatDisplayValue(generatedUtc, control.FormatString), _ when ReportFormulaEvaluator.TryParseAggregate(expression, out string functionName, out string fieldName) => ReportSql.FormatDisplayValue(ReportFormulaEvaluator.EvaluateAggregate(functionName, rows.Select(item => LookupFieldValue(item, fieldName))), control.FormatString), + _ when ReportFormulaEvaluator.TryEvaluateScalar(expression, field => LookupFieldValue(row, field), functions, callbackPolicy, out object? scalarValue) + => ReportSql.FormatDisplayValue(scalarValue, control.FormatString), _ when row is not null && ReportFormulaEvaluator.TryReadFieldReference(expression.TrimStart('='), out string boundFieldName) => ReportSql.FormatDisplayValue(LookupFieldValue(row, boundFieldName), control.FormatString), _ when row is not null @@ -360,7 +443,7 @@ _ when row is not null { object? fieldValue = LookupFieldValue(row, field); return ReportSql.TryConvertToDouble(fieldValue, out double numeric) ? numeric : null; - }), + }, functions, callbackPolicy), control.FormatString), _ => string.Empty, }; diff --git a/src/CSharpDB.Admin.Reports/Services/NullReportEventDispatcher.cs b/src/CSharpDB.Admin.Reports/Services/NullReportEventDispatcher.cs new file mode 100644 index 00000000..80dd322b --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Services/NullReportEventDispatcher.cs @@ -0,0 +1,21 @@ +using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Admin.Reports.Models; + +namespace CSharpDB.Admin.Reports.Services; + +public sealed class NullReportEventDispatcher : IReportEventDispatcher +{ + public static NullReportEventDispatcher Instance { get; } = new(); + + private NullReportEventDispatcher() + { + } + + public Task DispatchAsync( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind, + IReadOnlyDictionary? runtimeArguments = null, + CancellationToken ct = default) + => Task.FromResult(ReportEventDispatchResult.Success()); +} diff --git a/src/CSharpDB.Admin.Reports/Services/ReportAutomationMetadata.cs b/src/CSharpDB.Admin.Reports/Services/ReportAutomationMetadata.cs new file mode 100644 index 00000000..9a2e3f5f --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Services/ReportAutomationMetadata.cs @@ -0,0 +1,44 @@ +using CSharpDB.Admin.Reports.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Reports.Services; + +public static class ReportAutomationMetadata +{ + private const string Surface = "admin.reports"; + private static readonly string[] IgnoredFormulaFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + + public static ReportDefinition NormalizeForExport(ReportDefinition report) + { + ArgumentNullException.ThrowIfNull(report); + + DbAutomationMetadata metadata = Build(report); + return report with { Automation = metadata.IsEmpty ? null : metadata }; + } + + public static DbAutomationMetadata Build(ReportDefinition report) + { + ArgumentNullException.ThrowIfNull(report); + + var builder = new DbAutomationMetadataBuilder(); + foreach (ReportEventBinding binding in report.EventBindings ?? []) + builder.AddCommand(binding.CommandName, Surface, $"report.events.{binding.Event}"); + + foreach (ReportBandDefinition band in report.Bands) + { + foreach (ReportControlDefinition control in band.Controls) + AddScalarFunctions(builder, control.Expression, $"bands.{band.BandId}.controls.{control.ControlId}.expression"); + } + + return builder.Build(); + } + + private static void AddScalarFunctions(DbAutomationMetadataBuilder builder, string? expression, string location) + { + foreach (DbAutomationScalarFunctionCall call in + DbAutomationExpressionInspector.FindScalarFunctionCalls(expression, IgnoredFormulaFunctions)) + { + builder.AddScalarFunction(call.Name, call.Arity, Surface, location); + } + } +} diff --git a/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs b/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs index f1d7ec69..00990bf4 100644 --- a/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Reports.Services; public static class ReportFormulaEvaluator @@ -5,6 +8,19 @@ public static class ReportFormulaEvaluator private static readonly string[] AggregateFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; public static double? EvaluateNumeric(string? expression, Func fieldResolver) + => EvaluateNumeric(expression, fieldResolver, DbFunctionRegistry.Empty); + + public static double? EvaluateNumeric( + string? expression, + Func fieldResolver, + DbFunctionRegistry? functions) + => EvaluateNumeric(expression, fieldResolver, functions, callbackPolicy: null); + + public static double? EvaluateNumeric( + string? expression, + Func fieldResolver, + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy) { if (string.IsNullOrWhiteSpace(expression)) return null; @@ -19,7 +35,7 @@ public static class ReportFormulaEvaluator try { - var parser = new Parser(expr, fieldResolver); + var parser = new Parser(expr, fieldResolver, functions ?? DbFunctionRegistry.Empty, callbackPolicy); double? result = parser.ParseExpression(); if (parser.Position < parser.Input.Length) return null; @@ -32,6 +48,36 @@ public static class ReportFormulaEvaluator } } + public static bool TryEvaluateScalar( + string? expression, + Func fieldResolver, + DbFunctionRegistry? functions, + out object? value) + => TryEvaluateScalar(expression, fieldResolver, functions, callbackPolicy: null, out value); + + public static bool TryEvaluateScalar( + string? expression, + Func fieldResolver, + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy, + out object? value) + { + value = null; + if (functions == null || string.IsNullOrWhiteSpace(expression)) + return false; + + string expr = expression.Trim(); + if (!expr.StartsWith('=')) + return false; + + expr = expr[1..].Trim(); + if (!TryEvaluateFunctionCall(expr, fieldResolver, functions, callbackPolicy, out DbValue dbValue)) + return false; + + value = FromDbValue(dbValue); + return true; + } + public static bool TryParseAggregate(string? expression, out string functionName, out string fieldName) { functionName = fieldName = string.Empty; @@ -109,12 +155,20 @@ private ref struct Parser public int Position; private readonly Func _fieldResolver; - - public Parser(string input, Func fieldResolver) + private readonly DbFunctionRegistry _functions; + private readonly DbExtensionPolicy? _callbackPolicy; + + public Parser( + string input, + Func fieldResolver, + DbFunctionRegistry functions, + DbExtensionPolicy? callbackPolicy) { Input = input.AsSpan(); Position = 0; _fieldResolver = fieldResolver; + _functions = functions; + _callbackPolicy = callbackPolicy; } public double? ParseExpression() @@ -226,7 +280,14 @@ public Parser(string input, Func fieldResolver) return ParseNumber(); string? fieldReference = ParseFieldReference(); - return fieldReference is null ? null : _fieldResolver(fieldReference); + if (fieldReference is null) + return null; + + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == '(' && IsIdentifier(fieldReference)) + return ParseFunctionCall(fieldReference); + + return _fieldResolver(fieldReference); } private double? ParseNumber() @@ -270,10 +331,246 @@ public Parser(string input, Func fieldResolver) return Input[identifierStart..Position].ToString(); } + private double? ParseFunctionCall(string functionName) + { + Position++; + var arguments = new List(); + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == ')') + { + Position++; + return InvokeFunction(functionName, arguments); + } + + while (Position < Input.Length) + { + double? argument = ParseExpression(); + arguments.Add(argument.HasValue ? DbValue.FromReal(argument.Value) : DbValue.Null); + + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == ',') + { + Position++; + continue; + } + + if (Position < Input.Length && Input[Position] == ')') + { + Position++; + return InvokeFunction(functionName, arguments); + } + + return null; + } + + return null; + } + + private double? InvokeFunction(string functionName, List arguments) + { + if (!_functions.TryGetScalar(functionName, arguments.Count, out var definition)) + { + DbCallbackDiagnostics.WriteMissingScalarInvocation( + functionName, + arguments.Count, + CreateReportCallbackMetadata(functionName), + $"Unknown scalar function '{functionName}'."); + return null; + } + + if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) + return null; + + try + { + DbValue[] dbArguments = arguments.ToArray(); + IReadOnlyDictionary? metadata = CreateReportCallbackMetadata(functionName); + DbValue value = _callbackPolicy is null + ? definition.Invoke(dbArguments, metadata) + : definition.Invoke(dbArguments, metadata, _callbackPolicy, DbExtensionHostMode.Embedded); + return value.Type switch + { + DbType.Integer => value.AsInteger, + DbType.Real => value.AsReal, + _ => null, + }; + } + catch + { + return null; + } + } + private void SkipWhitespace() { while (Position < Input.Length && char.IsWhiteSpace(Input[Position])) Position++; } } + + private static bool TryEvaluateFunctionCall( + string expression, + Func fieldResolver, + DbFunctionRegistry functions, + DbExtensionPolicy? callbackPolicy, + out DbValue value) + { + value = DbValue.Null; + int openParen = expression.IndexOf('('); + if (openParen <= 0 || !expression.EndsWith(')')) + return false; + + string name = expression[..openParen].Trim(); + if (!IsIdentifier(name)) + return false; + + string[] argumentTokens = SplitArguments(expression[(openParen + 1)..^1]); + if (!functions.TryGetScalar(name, argumentTokens.Length, out var definition)) + { + DbCallbackDiagnostics.WriteMissingScalarInvocation( + name, + argumentTokens.Length, + CreateReportCallbackMetadata(name), + $"Unknown scalar function '{name}'."); + return false; + } + + var arguments = new DbValue[argumentTokens.Length]; + for (int i = 0; i < argumentTokens.Length; i++) + arguments[i] = EvaluateScalarArgument(argumentTokens[i].Trim(), fieldResolver, functions, callbackPolicy); + + if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) + { + value = DbValue.Null; + return true; + } + + IReadOnlyDictionary? metadata = CreateReportCallbackMetadata(name); + value = callbackPolicy is null + ? definition.Invoke(arguments, metadata) + : definition.Invoke(arguments, metadata, callbackPolicy, DbExtensionHostMode.Embedded); + return true; + } + + private static DbValue EvaluateScalarArgument( + string token, + Func fieldResolver, + DbFunctionRegistry functions, + DbExtensionPolicy? callbackPolicy) + { + if (TryEvaluateFunctionCall(token, fieldResolver, functions, callbackPolicy, out DbValue nestedValue)) + return nestedValue; + + if (token.StartsWith('\'') && token.EndsWith('\'') && token.Length >= 2) + return DbValue.FromText(token[1..^1]); + + if (string.Equals(token, "null", StringComparison.OrdinalIgnoreCase)) + return DbValue.Null; + + if (long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer)) + return DbValue.FromInteger(integer); + + if (double.TryParse(token, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double real)) + return DbValue.FromReal(real); + + if (TryReadFieldReference(token, out string fieldName)) + return ToDbValue(fieldResolver(fieldName)); + + return DbValue.FromText(token); + } + + private static string[] SplitArguments(string argumentsText) + { + if (string.IsNullOrWhiteSpace(argumentsText)) + return []; + + var arguments = new List(); + int start = 0; + int depth = 0; + bool inString = false; + for (int i = 0; i < argumentsText.Length; i++) + { + char ch = argumentsText[i]; + if (ch == '\'') + { + inString = !inString; + continue; + } + + if (inString) + continue; + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')') + { + depth--; + if (depth < 0) + return []; + continue; + } + + if (ch == ',' && depth == 0) + { + arguments.Add(argumentsText[start..i].Trim()); + start = i + 1; + } + } + + if (inString || depth != 0) + return []; + + arguments.Add(argumentsText[start..].Trim()); + return arguments.Any(static argument => argument.Length == 0) ? [] : arguments.ToArray(); + } + + private static DbValue ToDbValue(object? value) => value switch + { + null => DbValue.Null, + DbValue dbValue => dbValue, + bool boolean => DbValue.FromInteger(boolean ? 1 : 0), + byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + string text => DbValue.FromText(text), + byte[] bytes => DbValue.FromBlob(bytes), + _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; + + private static object? FromDbValue(DbValue value) => value.Type switch + { + DbType.Null => null, + DbType.Integer => value.AsInteger, + DbType.Real => value.AsReal, + DbType.Text => value.AsText, + DbType.Blob => value.AsBlob, + _ => null, + }; + + private static bool IsIdentifier(string value) + { + if (value.Length == 0 || (!char.IsLetter(value[0]) && value[0] != '_')) + return false; + + for (int i = 1; i < value.Length; i++) + { + if (!char.IsLetterOrDigit(value[i]) && value[i] != '_') + return false; + } + + return true; + } + + private static IReadOnlyDictionary? CreateReportCallbackMetadata(string functionName) + => DbCallbackDiagnostics.IsInvocationEnabled + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminReports", + ["location"] = $"expressions.functions.{functionName}", + ["correlationId"] = Guid.NewGuid().ToString("N"), + } + : null; } diff --git a/src/CSharpDB.Admin/CSharpDB.Admin.csproj b/src/CSharpDB.Admin/CSharpDB.Admin.csproj index 2bafa2c4..2c073bb4 100644 --- a/src/CSharpDB.Admin/CSharpDB.Admin.csproj +++ b/src/CSharpDB.Admin/CSharpDB.Admin.csproj @@ -9,6 +9,7 @@ + diff --git a/src/CSharpDB.Admin/Components/App.razor b/src/CSharpDB.Admin/Components/App.razor index f8aa1a15..23e4caf1 100644 --- a/src/CSharpDB.Admin/Components/App.razor +++ b/src/CSharpDB.Admin/Components/App.razor @@ -12,7 +12,7 @@ - + diff --git a/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor b/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor new file mode 100644 index 00000000..40fb3611 --- /dev/null +++ b/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor @@ -0,0 +1,346 @@ +@using CSharpDB.Primitives +@inject ICSharpDbClient DbClient +@inject IFormRepository FormRepository +@inject IReportRepository ReportRepository +@inject TabManagerService TabManager +@inject HostCallbackCatalogService CallbackCatalog +@inject DatabaseChangeService Changes +@inject ModalService Modal +@inject ToastService Toast +@inject IJSRuntime JS +@implements IDisposable + +@if (Visible) +{ +
+ +
+} + +@code { + [Parameter] public bool Visible { get; set; } + [Parameter] public EventCallback VisibleChanged { get; set; } + + private readonly List _items = []; + private string _query = string.Empty; + private bool _loading; + private bool _loaded; + + private IReadOnlyList FilteredItems + { + get + { + if (string.IsNullOrWhiteSpace(_query)) + return _items.Take(32).ToList(); + + string query = _query.Trim(); + return _items + .Where(item => item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) + || item.Subtitle.Contains(query, StringComparison.OrdinalIgnoreCase) + || item.Kind.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(32) + .ToList(); + } + } + + protected override async Task OnParametersSetAsync() + { + if (Visible && !_loaded && !_loading) + await LoadItemsAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (Visible) + { + try { await JS.InvokeVoidAsync("editorInterop.focus", "admin-command-palette-search"); } + catch { } + } + } + + private async Task LoadItemsAsync() + { + _loading = true; + try + { + _items.Clear(); + AddCoreCommands(); + await AddCallbackCommandsAsync(); + + IReadOnlyCollection tables = await DbClient.GetTableNamesAsync(); + foreach (string tableName in tables.Where(static name => !name.StartsWith("_", StringComparison.Ordinal)).OrderBy(name => name)) + { + string captured = tableName; + _items.Add(new PaletteItem( + "Table", + captured, + "Open table data", + "bi-table", + "icon-table", + () => { TabManager.OpenTableTab(captured); return Task.CompletedTask; })); + _items.Add(new PaletteItem( + "Schema", + $"Design {captured}", + "Open table schema", + "bi-file-earmark-code", + "icon-system", + () => { TabManager.OpenTableSchemaTab(captured); return Task.CompletedTask; })); + } + + IReadOnlyCollection views = await DbClient.GetViewNamesAsync(); + foreach (string viewName in views.OrderBy(name => name)) + { + string captured = viewName; + _items.Add(new PaletteItem( + "View", + captured, + "Open view data", + "bi-eye", + "icon-view", + () => { TabManager.OpenViewTab(captured); return Task.CompletedTask; })); + } + + IReadOnlyCollection collections = await DbClient.GetCollectionNamesAsync(); + foreach (string collectionName in collections.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)) + { + string captured = collectionName; + _items.Add(new PaletteItem( + "Collection", + captured, + "Open collection documents", + "bi-braces", + "icon-view", + () => { TabManager.OpenCollectionTab(captured); return Task.CompletedTask; })); + } + + foreach (ProcedureDefinition procedure in (await DbClient.GetProceduresAsync()).OrderBy(procedure => procedure.Name)) + { + string captured = procedure.Name; + _items.Add(new PaletteItem( + "Procedure", + captured, + procedure.Description ?? "Open procedure", + "bi-gear-wide-connected", + "icon-trigger", + () => { TabManager.OpenProcedureTab(captured); return Task.CompletedTask; })); + } + + foreach (FormDefinition form in (await FormRepository.ListAsync()).OrderBy(form => form.Name, StringComparer.OrdinalIgnoreCase)) + { + FormDefinition captured = form; + _items.Add(new PaletteItem( + "Form", + captured.Name, + $"Design form for {captured.TableName}", + "bi-ui-checks-grid", + "icon-form", + () => { TabManager.OpenFormDesignerTab(captured.FormId, title: captured.Name); return Task.CompletedTask; })); + _items.Add(new PaletteItem( + "Form", + $"Open {captured.Name}", + $"Data entry for {captured.TableName}", + "bi-file-earmark-text", + "icon-form", + () => { TabManager.OpenFormEntryTab(captured.FormId, captured.Name); return Task.CompletedTask; })); + } + + foreach (ReportDefinition report in (await ReportRepository.ListAsync()).OrderBy(report => report.Name, StringComparer.OrdinalIgnoreCase)) + { + ReportDefinition captured = report; + _items.Add(new PaletteItem( + "Report", + captured.Name, + $"Design report from {captured.Source.Name}", + "bi-file-earmark-richtext", + "icon-report", + () => { TabManager.OpenReportDesignerTab(captured.ReportId, title: captured.Name); return Task.CompletedTask; })); + _items.Add(new PaletteItem( + "Report", + $"Preview {captured.Name}", + $"Render report from {captured.Source.Name}", + "bi-printer", + "icon-report", + () => { TabManager.OpenReportPreviewTab(captured.ReportId, captured.Name); return Task.CompletedTask; })); + } + + _loaded = true; + } + catch + { + _items.Clear(); + AddCoreCommands(); + _loaded = true; + } + finally + { + _loading = false; + } + } + + private void AddCoreCommands() + { + _items.Add(new PaletteItem("Command", "New Query", "Open a SQL editor", "bi-terminal", "icon-system", () => { TabManager.OpenQueryTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Table", "Open table designer", "bi-table", "icon-table", () => { TabManager.OpenTableDesignerTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Collection", "Create or open a document collection", "bi-braces", "icon-view", OpenNewCollectionAsync)); + _items.Add(new PaletteItem("Command", "Callbacks", "Open host callback catalog", "bi-plug", "icon-system", () => { TabManager.OpenCallbacksTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Form", "Open form designer", "bi-ui-checks-grid", "icon-form", () => { TabManager.OpenFormDesignerTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Report", "Open report designer", "bi-file-earmark-richtext", "icon-report", () => { TabManager.OpenReportDesignerTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Procedure", "Open procedure editor", "bi-gear-wide-connected", "icon-trigger", () => { TabManager.OpenNewProcedureTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Pipeline", "Open pipeline builder", "bi-diagram-3", "icon-view", () => { TabManager.OpenPipelineTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "Storage Inspector", "Open storage diagnostics", "bi-hdd-stack", "icon-system", () => { TabManager.OpenStorageTab(); return Task.CompletedTask; })); + } + + private async Task AddCallbackCommandsAsync() + { + foreach (HostCallbackCatalogEntry callback in await CallbackCatalog.GetEntriesAsync()) + { + HostCallbackCatalogEntry captured = callback; + _items.Add(new PaletteItem( + captured.IsMissingRegistration ? "Missing callback" : GetCallbackKindLabel(captured.Kind), + captured.Name, + FormatCallbackSubtitle(captured), + captured.IsMissingRegistration ? "bi-exclamation-triangle" : "bi-plug", + captured.IsMissingRegistration ? "icon-report" : "icon-system", + () => + { + TabManager.OpenCallbacksTab(captured.Name, captured.Kind.ToString(), captured.Arity); + return Task.CompletedTask; + })); + } + } + + private async Task OpenNewCollectionAsync() + { + string? enteredName = await Modal.PromptAsync( + "New Collection", + CollectionNameValidator.HelpText, + "Create", + "collection_name"); + if (enteredName is null) + return; + + string collectionName = CollectionNameValidator.Normalize(enteredName); + if (!CollectionNameValidator.IsValid(collectionName)) + { + Toast.Error(CollectionNameValidator.HelpText); + return; + } + + try + { + await DbClient.BrowseCollectionAsync(collectionName, page: 1, pageSize: 1); + TabManager.OpenCollectionTab(collectionName); + Changes.NotifyChanged(); + } + catch (Exception ex) + { + Toast.Error(ex.Message); + } + } + + private Task OnQueryChanged(ChangeEventArgs e) + { + _query = e.Value?.ToString() ?? string.Empty; + return Task.CompletedTask; + } + + private async Task HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + await CloseAsync(); + } + + private async Task ExecuteAsync(PaletteItem item) + { + await item.Execute(); + await CloseAsync(); + } + + private async Task CloseAsync() + { + _query = string.Empty; + await VisibleChanged.InvokeAsync(false); + } + + private void OnChanged() + { + _loaded = false; + if (Visible) + _ = InvokeAsync(LoadItemsAsync); + } + + protected override void OnInitialized() + { + Changes.Changed += OnChanged; + } + + public void Dispose() + { + Changes.Changed -= OnChanged; + } + + private static string GetCallbackKindLabel(AutomationCallbackKind kind) + => kind switch + { + AutomationCallbackKind.ScalarFunction => "Scalar function", + AutomationCallbackKind.Command => "Command", + _ => kind.ToString(), + }; + + private static string FormatCallbackSubtitle(HostCallbackCatalogEntry callback) + { + if (callback.IsMissingRegistration) + return $"Missing registration, referenced {callback.References.Count} time{(callback.References.Count == 1 ? "" : "s")}"; + + if (!string.IsNullOrWhiteSpace(callback.Descriptor?.Description)) + return callback.Descriptor.Description; + + return callback.Descriptor is null || callback.Descriptor.Capabilities.Count == 0 + ? callback.Descriptor?.Runtime.ToString() ?? "Callback" + : $"{callback.Descriptor.Runtime} · {string.Join(", ", callback.Descriptor.Capabilities.Select(static capability => capability.Name))}"; + } + + private sealed record PaletteItem( + string Kind, + string Title, + string Subtitle, + string Icon, + string IconClass, + Func Execute); + +} diff --git a/src/CSharpDB.Admin/Components/Layout/MainLayout.razor b/src/CSharpDB.Admin/Components/Layout/MainLayout.razor index f7ade104..9b77d2d7 100644 --- a/src/CSharpDB.Admin/Components/Layout/MainLayout.razor +++ b/src/CSharpDB.Admin/Components/Layout/MainLayout.razor @@ -6,7 +6,9 @@ @implements IDisposable
- +
@if (_sidebarVisible) { @@ -40,6 +42,15 @@ ObjectName="@(tab.ObjectName ?? string.Empty)" IsView="true" /> break; + case TabKind.CollectionData: + + break; + case TabKind.HostCallbacks: + + break; case TabKind.Procedure: @@ -82,9 +93,11 @@ + @code { private bool _sidebarVisible = true; + private bool _commandPaletteVisible; private DotNetObjectReference? _dotNetRef; protected override async Task OnAfterRenderAsync(bool firstRender) @@ -112,6 +125,9 @@ case "ToggleTheme": await Theme.ToggleAsync(); break; + case "OpenCommandPalette": + OpenCommandPalette(); + break; case "CloseTab": if (TabManager.ActiveTab?.Closable == true) TabManager.CloseTab(TabManager.ActiveTab.Id); @@ -129,11 +145,17 @@ StateHasChanged(); } + private void OpenCommandPalette() + { + _commandPaletteVisible = true; + StateHasChanged(); + } + private async Task ShowShortcuts() { await Modal.ConfirmAsync( "Keyboard Shortcuts", - "Ctrl+N \u2014 New query tab\nCtrl+B \u2014 Toggle sidebar\nCtrl+Enter \u2014 Run query\nCtrl+Shift+L \u2014 Toggle theme\nCtrl+W \u2014 Close tab\nDouble-click \u2014 Edit cell", + "Ctrl+K - Command palette\nCtrl+N - New query tab\nCtrl+B - Toggle sidebar\nCtrl+Enter - Run query\nCtrl+Shift+L - Toggle theme\nCtrl+W - Close tab\nDouble-click - Edit cell", "OK"); } diff --git a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor index d84b254b..6f4edd38 100644 --- a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor +++ b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor @@ -1,9 +1,11 @@ +@using CSharpDB.Admin.Forms.Evaluation @inject ICSharpDbClient DbClient @inject DatabaseClientHolder DbHolder @inject DatabaseChangeService Changes @inject IFormRepository FormRepository @inject IReportRepository ReportRepository @inject TabManagerService TabManager +@inject HostCallbackCatalogService CallbackCatalog @inject ToastService Toast @inject ModalService Modal @inject IJSRuntime JS @@ -12,18 +14,83 @@