diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 14510fe..b37b13e 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -1,16 +1,20 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "fantomas": { - "version": "6.3.16", - "commands": ["fantomas"], - "rollForward": true - }, - "fsdocs-tool": { - "version": "20.0.1", - "commands": ["fsdocs"], - "rollForward": true - } - } -} +{ + "version": 1, + "isRoot": true, + "tools": { + "fantomas": { + "version": "7.0.5", + "commands": [ + "fantomas" + ], + "rollForward": true + }, + "fsdocs-tool": { + "version": "22.1.0", + "commands": [ + "fsdocs" + ], + "rollForward": true + } + } +} \ No newline at end of file diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0c7f8f3..750d75f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ -FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04 +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 -RUN curl -fsSL https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.sh | bash /dev/stdin -c 8.0 --install-dir /home/vscode/.dotnet +RUN curl -fsSL https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.sh | bash /dev/stdin -c 10.0 --install-dir /home/vscode/.dotnet ENV DOTNET_ROOT=/home/vscode/.dotnet ENV PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d350d55..534537d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -16,6 +16,7 @@ "extensions": [ "Ionide.Ionide-fsharp", "ms-dotnettools.csharp", + "ms-dotnettools.vscode-dotnet-runtime", "EditorConfig.EditorConfig", //"GitHub.copilot", //"GitHub.copilot-chat", diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7d21588..fd14d88 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,8 +5,10 @@ on: push: branches: - main + - vnext paths: - docs/** + - .github/workflows/docs.yml # We need some permissions to publish to Github Pages permissions: @@ -18,11 +20,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Restore tools run: dotnet tool restore - name: Build code diff --git a/.github/workflows/library.yml b/.github/workflows/library.yml index 492a0f4..aaea30a 100644 --- a/.github/workflows/library.yml +++ b/.github/workflows/library.yml @@ -7,35 +7,21 @@ on: - dev paths: - src/** - - tests/** pull_request: branches: [main] paths: - src/** - - tests/** jobs: build: runs-on: ubuntu-latest - name: Build dotnet 8.0 + name: Build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - run: dotnet restore - - run: dotnet build Navs.sln -f net8.0 --configuration Release --no-restore - - run: dotnet test Navs.sln -f net8.0 --no-restore - buildnet6: - runs-on: ubuntu-latest - name: Build dotnet 6.0 - steps: - - uses: actions/checkout@v4 - - name: Setup dotnet - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "6.0.x" - - run: dotnet restore - - run: dotnet build Navs.sln -f net6.0 --configuration Release --no-restore - - run: dotnet test Navs.sln -f net6.0 --no-restore + - run: dotnet build Navs.slnx --configuration Release --no-restore + - run: dotnet test Navs.slnx --no-restore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0186e37 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,92 @@ +name: Create Release + +on: + workflow_dispatch: + +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Extract Release Data + id: extract + run: | + VERSION_LINE=$(grep -n "^## \[[0-9]" CHANGELOG.md | head -n 1) + + if [ -z "$VERSION_LINE" ]; then + echo "No version found in CHANGELOG.md" + exit 1 + fi + + LINE_NUM=$(echo "$VERSION_LINE" | cut -d: -f1) + VERSION=$(echo "$VERSION_LINE" | sed -E 's/.*## \[([^]]+)\].*/\1/') + + echo "Found version: $VERSION at line $LINE_NUM" + + NEXT_HEADER_LINE=$(tail -n +$((LINE_NUM + 1)) CHANGELOG.md | grep -n "^## \[" | head -n 1 | cut -d: -f1) + + if [ -z "$NEXT_HEADER_LINE" ]; then + BODY=$(tail -n +$((LINE_NUM + 1)) CHANGELOG.md) + else + BODY=$(tail -n +$((LINE_NUM + 1)) CHANGELOG.md | head -n $((NEXT_HEADER_LINE - 1))) + fi + + echo "$BODY" > RELEASE_NOTES.md + + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Check if Tag Exists + id: check_tag + run: | + if git rev-parse "v${{ steps.extract.outputs.version }}" >/dev/null 2>&1; then + echo "Tag v${{ steps.extract.outputs.version }} already exists." + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Setup .NET + if: steps.check_tag.outputs.exists == 'false' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Install dotnet tools + if: steps.check_tag.outputs.exists == 'false' + run: dotnet tool restore + + - name: Restore dependencies + if: steps.check_tag.outputs.exists == 'false' + run: dotnet restore + + - name: Run Tests + if: steps.check_tag.outputs.exists == 'false' + run: dotnet test --configuration Release --no-restore + + - name: Build + if: steps.check_tag.outputs.exists == 'false' + run: dotnet build --configuration Release --no-restore + + - name: Pack + if: steps.check_tag.outputs.exists == 'false' + run: dotnet pack --configuration Release --no-build --output nupkgs + + - name: Publish to NuGet + if: steps.check_tag.outputs.exists == 'false' + run: dotnet nuget push "nupkgs/*.nupkg" --api-key ${{ secrets.NUGET_DEPLOY_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Create Release + if: steps.check_tag.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.extract.outputs.version }} + name: v${{ steps.extract.outputs.version }} + body_path: RELEASE_NOTES.md + draft: false + prerelease: ${{ contains(steps.extract.outputs.version, '-rc') || contains(steps.extract.outputs.version, '-beta') || contains(steps.extract.outputs.version, '-alpha') }} + files: nupkgs/*.nupkg diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 4ff78d0..cf3c026 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -7,7 +7,6 @@ on: - dev paths: - src/** - - tests/** - samples/** pull_request: branches: @@ -20,12 +19,12 @@ on: jobs: build: runs-on: ubuntu-latest - name: Build dotnet 8.0 + name: Build dotnet 10.0 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" - - run: dotnet restore samples/Samples.sln - - run: dotnet build samples/Samples.sln -f net8.0 --configuration Release --no-restore + dotnet-version: "10.0.x" + - run: dotnet restore samples/Samples.slnx + - run: dotnet build samples/Samples.slnx -f net10.0 --configuration Release --no-restore diff --git a/.vscode/launch.json b/.vscode/launch.json index 631fd82..e49e47b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,31 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Debug Terminal.Gui Sample", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build:tgui", + "program": "${workspaceFolder}/samples/TGUI/bin/Debug/net9.0/TGUI.dll", + "args": [ + "tgui:///login?username=admin" + ], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "console": "externalTerminal" + }, + { + "name": "Debug Declarative Sample", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build:declarative", + "program": "${workspaceFolder}/samples/NxuiDeclarative/bin/Debug/net9.0/NxuiDeclarative.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "console": "integratedTerminal" + }, + { "name": "Debug Router Tests", "type": "coreclr", @@ -14,6 +39,7 @@ "cwd": "${workspaceFolder}", "stopAtEntry": false, "console": "internalConsole" - } + }, + ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index adaa0b0..7411e5c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -16,6 +16,38 @@ "-f", "net8.0" ] + }, + { + "command": "dotnet", + "label": "build:tgui", + "type": "shell", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile", + "args": [ + "build", + "${workspaceFolder}/samples/TGUI/TGUI.fsproj", + "-f", + "net9.0" + ] + }, + { + "command": "dotnet", + "label": "build:declarative", + "type": "shell", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile", + "args": [ + "build", + "${workspaceFolder}/samples/NxuiDeclarative/NxuiDeclarative.fsproj", + "-f", + "net9.0" + ] } ] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..59a1af4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# The Navs Family + +The Navs family is a set of router-like abstractions for F#. + +- **Navs** — core router (inspired by web routers such as vue-router and angular-router), generic and suitable for application-specific adapters. +- **UrlTemplates** — URL template parsing and matching, used by Navs to parse navigable URLs. +- **Navs.Avalonia** — adapter for Avalonia applications. +- **Navs.FuncUI** — adapter for Avalonia.FuncUI applications. +- **Navs.Terminal.Gui** — adapter for Terminal.Gui applications. + +General setup and usage instructions can be found in the [README.md](README.md) file. + +## Imperatives + +1. **NEVER PUSH WITHOUT PERMISSION.** Always ask before pushing to the remote. +2. **NEVER FORCE PUSH.** Tell the user they have to force push instead of you. +3. **Always run `dotnet fantomas .` before committing code.** Format all F# files before staging. +4. **Never use `Option.get` or `ValueOption.get`.** Always pattern match (`match`, `function`, `if ... then`) or use safe alternatives (`Option.defaultValue`, `Option.map`, `Array.choose`, etc.) to handle option values. Unchecked `.get` calls crash at runtime on `None`. +5. Pull requests made with the `gh` command should use a markdown file as the PR body, not inline escaped markdown strings. + +## Project Structure + +- `src/` contains the main source. + - Packable libraries: + - `UrlTemplates` + - `Navs` + - `Navs.Avalonia` + - `Navs.FuncUI` + - `Navs.Terminal.Gui` + - Test projects (not packable): + - `Navs.Tests` + - `UrlTemplates.Tests` +- `samples/` contains sample applications (not packable): + - `Routerish` + - `FuncUI` + - `TGUI` + - `NxuiDeclarative` +- `docs/` contains the [FsDocs](https://fsprojects.github.io/FSharp.Formatting/) documentation site. + +Multi-targeting: most libraries target `net10.0;net8.0`; `Navs.Terminal.Gui` targets `net10.0` only. + +## Changelog Management + +We follow https://github.com/ionide/KeepAChangelog guidelines. + +Changelog format: + +```markdown +# Changelog + +## [Unreleased] + +Content that is pending for release goes here. + +## [1.0.0] - 2026-06-24 + +### Added + +- Initial release +``` + +Each section may contain the following categories: + +- Added +- Changed +- Deprecated +- Removed +- Fixed +- Security + +When adding entries to the changelog, make sure to follow format and categories. + +### Writing style + +The changelog is written for **developers upgrading their version**, not as a development journal. Keep these rules in mind: + +1. **Concise and reader-focused.** Each entry is one bullet that says what changed and why a user cares — not how it's implemented internally. No internal module/file paths, no build/milestone/phase numbers (e.g. "B12", "Phase 3"), no section references (e.g. "§6.2"), and no "mirrors the canonical X" narration. A reader should understand the entry without reading the code. + +2. **Group by user-facing concern, not by task.** One bullet per feature/fix area. If multiple commits touch the same subsystem, collapse them into one bullet that names each fix briefly, rather than one bullet per commit. + +3. **Only released code can be Changed or Fixed.** Features that have never shipped belong in `Added` — there is no prior version to change from or fix against. Design choices and implementation details of a new feature are part of its `Added` description, not separate `Fixed`/`Changed` entries. Use `Changed`/`Fixed` only for modifications to already-released behavior (and mark breakage with **Breaking:** or **Breaking (behavioral):**). + +4. **Lead with the affected surface.** Bold-prefix each bullet with the area: `**Navs:**`, `**UrlTemplates:**`, `**Navs.Avalonia:**`, `**Navs.FuncUI:**`, `**Navs.Terminal.Gui:**`, `**CI:**`, `**Project:**`, etc. Keep breaking changes at the top of their category. + +5. **Plain language.** Describe the user-visible effect, not the code diff. The reader wants to know what they'll observe, not what line changed. + +### Versioning + +The package version is derived from `CHANGELOG.md` at pack time by `Ionide.KeepAChangelog.Tasks`. There is no need to edit versions in project files or CI manually. Cutting a release is done by moving the desired content from `[Unreleased]` into a new version section, then triggering the release workflow. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a6cd7a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +## [Unreleased] + +### Changed + +- **Navs (breaking):** `RouteGuard` is now a two-argument delegate (`RouteContext voption * RouteContext`) and uses `IcedTasks.CancellableValueTask`. Update existing guards to accept the previous route (or `ValueNone`) and the target route, and to return a `CancellableValueTask`. +- **Navs (breaking):** Nested routes and `RouteDefinition.children` have been removed. Define all routes at the top level. +- **Navs.Avalonia (breaking):** The view type is now `Control` instead of `#Control`. Existing route handlers returning specific Avalonia control subtypes may need explicit upcasts. +- **Core:** Central package management enabled; all dependencies centralized in `Directory.Packages.props`. +- **Core:** Samples upgraded to `net10.0` and solution files converted to `.slnx`. +- **Navs:** Router refactored for improved modularity, cancellable navigation, and async guard activation/deactivation. +- **Navs.Avalonia:** Updated to Avalonia 12.0.5 and fixed nullability warnings. +- **Navs.FuncUI:** Updated Avalonia/FuncUI dependencies and added logging support. +- **Project:** Fantomas 7.0.2 and `enable` applied across the solution. + +### Added + +- **Navs.Terminal.Gui:** New adapter library for Terminal.Gui applications. +- **Navs:** Optional `ILogger` support across `Router`, adapters, and `RouterOutlet` for diagnostics during navigation, guards, cache hits, redirects, and route resolution. +- **Navs:** `RouteContext` query-parameter helpers (`getParamSequence`) and an `IDisposableBag` for per-route cleanup. +- **Navs:** `NavigationError<'View>` discriminated union with explicit cases for same-route, cancellation, not found, activation/deactivation failures, and guard redirects. +- **Navs:** Optional `maxCyclicRedirects` parameter on `Router.build` (defaults to 5) to bound how many distinct redirect pairs the router follows before bailing; protects against redirect cycles. +- **UrlTemplates:** Query-parameter parsing improvements and corrected error-message spelling. +- **CI:** Release workflow that extracts the version from this changelog, runs tests, packs, and publishes to NuGet and GitHub Releases. +- **Project:** `AGENTS.md` with agent imperatives and changelog conventions. + +### Fixed + +- **Navs:** The previous active route's resources are now disposed only after activation guards and content resolution pass, so a failed navigation no longer leaves a broken view on screen. +- **Navs:** Redirect chains are protected against cycles and unbounded distinct-redirect counts; the router reports a clear error instead of looping indefinitely. +- **Navs.FuncUI:** Initial-navigation failures now log the underlying exception rather than discarding it. +- **Navs.Terminal.Gui:** The `Text` binding extension now reads/writes `Text` instead of `Title`. +- **UrlTemplates:** Corrected a "Requred" typo in the missing-parameter error message. + +## [1.0.0-rc-008] - 2025-06-17 + +### Changed + +- **Navs family:** Last release before central package management and automated changelog-driven releases. No structured changelog was kept prior to this version. diff --git a/Navs.sln b/Navs.sln deleted file mode 100644 index f54a4e7..0000000 --- a/Navs.sln +++ /dev/null @@ -1,64 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9BCA35C8-193C-429C-B264-5D888B4C21FC}" -EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "UrlTemplates", "src\UrlTemplates\UrlTemplates.fsproj", "{A6C669DA-4290-4F79-B924-38BCEA94E387}" -EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Navs", "src\Navs\Navs.fsproj", "{B7FD735D-BE0B-4926-98E7-E3326EB7896F}" -EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Navs.Avalonia", "src\Navs.Avalonia\Navs.Avalonia.fsproj", "{87522EC2-C1FC-415D-B42F-9FF37BD82E32}" -EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Navs.FuncUI", "src\Navs.FuncUI\Navs.FuncUI.fsproj", "{F1D19955-34F0-4FA7-A756-996C5A599A2C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{BDD01E22-E97A-405D-A67B-FFA841CC4C83}" -EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "UrlTemplates.Tests", "tests\UrlTemplates.Tests\UrlTemplates.Tests.fsproj", "{6641DC68-80B4-4B9A-9109-F065D059D264}" -EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Navs.Tests", "tests\Navs.Tests\Navs.Tests.fsproj", "{14FB4B6D-F8D7-4BBE-9383-53D8D150E916}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A6C669DA-4290-4F79-B924-38BCEA94E387}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A6C669DA-4290-4F79-B924-38BCEA94E387}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A6C669DA-4290-4F79-B924-38BCEA94E387}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A6C669DA-4290-4F79-B924-38BCEA94E387}.Release|Any CPU.Build.0 = Release|Any CPU - {B7FD735D-BE0B-4926-98E7-E3326EB7896F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B7FD735D-BE0B-4926-98E7-E3326EB7896F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B7FD735D-BE0B-4926-98E7-E3326EB7896F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B7FD735D-BE0B-4926-98E7-E3326EB7896F}.Release|Any CPU.Build.0 = Release|Any CPU - {87522EC2-C1FC-415D-B42F-9FF37BD82E32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {87522EC2-C1FC-415D-B42F-9FF37BD82E32}.Debug|Any CPU.Build.0 = Debug|Any CPU - {87522EC2-C1FC-415D-B42F-9FF37BD82E32}.Release|Any CPU.ActiveCfg = Release|Any CPU - {87522EC2-C1FC-415D-B42F-9FF37BD82E32}.Release|Any CPU.Build.0 = Release|Any CPU - {F1D19955-34F0-4FA7-A756-996C5A599A2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F1D19955-34F0-4FA7-A756-996C5A599A2C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F1D19955-34F0-4FA7-A756-996C5A599A2C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F1D19955-34F0-4FA7-A756-996C5A599A2C}.Release|Any CPU.Build.0 = Release|Any CPU - {6641DC68-80B4-4B9A-9109-F065D059D264}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6641DC68-80B4-4B9A-9109-F065D059D264}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6641DC68-80B4-4B9A-9109-F065D059D264}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6641DC68-80B4-4B9A-9109-F065D059D264}.Release|Any CPU.Build.0 = Release|Any CPU - {14FB4B6D-F8D7-4BBE-9383-53D8D150E916}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {14FB4B6D-F8D7-4BBE-9383-53D8D150E916}.Debug|Any CPU.Build.0 = Debug|Any CPU - {14FB4B6D-F8D7-4BBE-9383-53D8D150E916}.Release|Any CPU.ActiveCfg = Release|Any CPU - {14FB4B6D-F8D7-4BBE-9383-53D8D150E916}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {A6C669DA-4290-4F79-B924-38BCEA94E387} = {9BCA35C8-193C-429C-B264-5D888B4C21FC} - {B7FD735D-BE0B-4926-98E7-E3326EB7896F} = {9BCA35C8-193C-429C-B264-5D888B4C21FC} - {87522EC2-C1FC-415D-B42F-9FF37BD82E32} = {9BCA35C8-193C-429C-B264-5D888B4C21FC} - {F1D19955-34F0-4FA7-A756-996C5A599A2C} = {9BCA35C8-193C-429C-B264-5D888B4C21FC} - {6641DC68-80B4-4B9A-9109-F065D059D264} = {BDD01E22-E97A-405D-A67B-FFA841CC4C83} - {14FB4B6D-F8D7-4BBE-9383-53D8D150E916} = {BDD01E22-E97A-405D-A67B-FFA841CC4C83} - EndGlobalSection -EndGlobal diff --git a/Navs.slnx b/Navs.slnx new file mode 100644 index 0000000..5a5e979 --- /dev/null +++ b/Navs.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/README.md b/README.md index 94bc366..a40cb67 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Navs is a router-like abstraction inspired by web routers such as vue-router, an It is primarily a "core" library which you would usually depend on in your own projects, as it is very generic and while F# can be very intelligent about type inference, it tends to produce quite verbose signatures. For more information visit the Navs section in these docs. -- [Navs](./Navs.fsx) +- [Navs](https://angelmunoz.github.io/Navs/Navs.html) A Compelling Example: @@ -32,14 +32,14 @@ let routes = [ fun context _ -> async { do! Async.Sleep(90) return - match context.UrlMatch.Params.TryGetValue "id" with - | true, id -> sprintf "Home %A" id - | false, _ -> "Guid No GUID" + match context.getParam "id" with + | ValueSome id -> sprintf "Home %A" id + | ValueNone -> "Guid No GUID" } ) ] -let router = Router.get(routes) +let router = Router.build(routes) router.Content.AddCallback(fun content -> printfn $"%A{content}") @@ -57,7 +57,7 @@ let! result3 = router.navigate("/123e4567-e89b-12d3-a456-426614174000") This project attempts to hide the generics from call sites and offer a few DSLs to make it easier to use Navs in Avalonia applications. This router was designed to be used with Raw Avalonia Control classes however, it will pair very nicely with the [NXUI](https://github.com/wieslawsoltes/NXUI) project, Feel free to check the C# and F# samples in the [Samples](https://github.com/AngelMunoz/Navs/tree/main/samples) folder in the source code repository. -- [Navs.Avalonia](./Navs.Avalonia/index.md) +- [Navs.Avalonia](https://angelmunoz.github.io/Navs/Navs-Avalonia.html) A Compelling Example: @@ -73,7 +73,7 @@ let routes = [ do! Async.Sleep(90) return // extract parameters from the URL - match context.urlMatch |> UrlMatch.getFromParams "id" with + match context.getParam "id" with | ValueSome id -> TextBlock().text(sprintf "Home %A" id) | ValueNone -> TextBlock().text("Guid No GUID") } @@ -82,13 +82,6 @@ let routes = [ Route.define("books", "/books", (fun _ _ -> TextBlock().text("Books"))) ] -let getMainContent (router: IRouter) = - ContentControl() - .DockTop() - // with NXUI you can use the .content method to bind the content - // to the observable in a seamless way - .content(router.Content |> AVal.toBinding) - let navigate url (router: IRouter) _ _ = task { // navigation is asynchronous and returns a result @@ -120,7 +113,7 @@ let app () = .content("Guid") .OnClickHandler(navigate $"/{Guid.NewGuid()}" router) ), - getMainContent(router) + RouterOutlet().router(router) ) ) @@ -132,7 +125,7 @@ NXUI.Run(app, "Navs.Avalonia!", Environment.GetCommandLineArgs()) |> ignore In a similar Fashion of Navs.Avalonia, this project attempts to provide a smooth API interface for [Avalonia.FuncUI](https://github.com/fsprojects/Avalonia.FuncUI/), you can find a sample in the [Samples](https://github.com/AngelMunoz/Navs/tree/main/samples) folder in the source code repository. -- [Navs.FuncUI](./Navs.FuncUI/index.md) +- [Navs.FuncUI](https://angelmunoz.github.io/Navs/Navs-FuncUI.html) A Compelling Example: @@ -150,7 +143,7 @@ let routes = [ fun context _ -> async { return TextBlock.create [ - match context.urlMatch |> UrlMatch.getFromParams "id" with + match context.getParam "id" with | ValueSome id -> TextBlock.text $"Visited: {id}" | ValueNone -> TextBlock.text "Guid No GUID" ] @@ -176,7 +169,7 @@ This is a library for parsing URL-like strings into structured objects. It is us Currently this library is mainly aimed to be used from F# but if there's interest in using it from C# I can add some more friendly APIs. -- [UrlTemplates](./UrlTemplates.fsx) +- [UrlTemplates](https://angelmunoz.github.io/Navs/UrlTemplates.html) A Compelling Example: diff --git a/build.fsx b/build.fsx deleted file mode 100644 index a7099e2..0000000 --- a/build.fsx +++ /dev/null @@ -1,70 +0,0 @@ -#r "nuget: Fun.Result, 2.0.9" -#r "nuget: Fun.Build, 1.0.9" - -open System.IO -open Fun.Build - -let version = "1.0.0-beta-008" - - -let build name = stage $"Build {name}" { run $"dotnet build src/{name}" } - -let pack name = stage $"Pack {name}" { - run $"dotnet pack src/{name} -p:Version={version} -o dist" -} - -let pushNugets = stage $"Push to NuGet" { - - run(fun ctx -> async { - - let nugetApiKey = ctx.GetEnvVar "NUGET_DEPLOY_KEY" - let nugets = Directory.GetFiles(__SOURCE_DIRECTORY__ + "/dist", "*.nupkg") - - for nuget in nugets do - printfn "Pushing %s" nuget - - let! res = - ctx.RunSensitiveCommand - $"dotnet nuget push {nuget} --skip-duplicate -s https://api.nuget.org/v3/index.json -k {nugetApiKey}" - - match res with - | Ok _ -> return () - | Error err -> failwith err - }) -} - -pipeline "nuget" { - - build "UrlTemplates" - build "Navs" - build "Navs.Avalonia" - build "Navs.FuncUI" - pack "UrlTemplates" - pack "Navs" - pack "Navs.Avalonia" - pack "Navs.FuncUI" - pushNugets - runIfOnlySpecified true -} - -pipeline "build" { - - build "UrlTemplates" - build "Navs" - build "Navs.Avalonia" - build "Navs.FuncUI" - runIfOnlySpecified false -} - -pipeline "nuget:local" { - build "UrlTemplates" - build "Navs" - build "Navs.Avalonia" - pack "UrlTemplates" - pack "Navs" - pack "Navs.Avalonia" - pack "Navs.FuncUI" - runIfOnlySpecified true -} - -tryPrintPipelineCommandHelp() diff --git a/docs/Navs-Avalonia.md b/docs/Navs-Avalonia.md index c5fe8ba..acd080b 100644 --- a/docs/Navs-Avalonia.md +++ b/docs/Navs-Avalonia.md @@ -17,7 +17,7 @@ That being said... ## Usage [hide] - #r "nuget: Navs.Avalonia, 1.0.0-beta-008" + #r "nuget: Navs.Avalonia, 1.0.0-rc-005" #r "nuget: FSharp.Data.Adaptive, 1.2.14" Using this library is very similar to using the base Navs library. The main difference is that the `Navs.Avalonia` library provides a less generic versions of the API. @@ -246,11 +246,10 @@ Hoisting events is a common pattern and tends to be the most flexible way to han For cases where you might want to _bind_ the value to a control and make changes propagate automatically, you can use `changeable values` which are adaptive values that can be set directly. let myTextBox(value: cval) = + // requires open Navs.Avalonia TextBox() - .text( - value - |> CVal.toBinding - ) + .text(value |> AVal.toBinding) + .OnTextChangedHandler(fun sender _ -> sender.Text |> AVal.setValue value) let parent() = let sharedValue = cval "Hello" diff --git a/docs/Navs-FuncUI.md b/docs/Navs-FuncUI.md index 69e7c41..f4a7e1e 100644 --- a/docs/Navs-FuncUI.md +++ b/docs/Navs-FuncUI.md @@ -6,7 +6,7 @@ category: Libraries --- [hide] - #r "nuget: Navs.Avalonia, 1.0.0-beta-008" + #r "nuget: Navs.Avalonia, 1.0.0-rc-005" ## Navs.FuncUI diff --git a/docs/Navs-Terminal.Gui.md b/docs/Navs-Terminal.Gui.md new file mode 100644 index 0000000..2e0ac9a --- /dev/null +++ b/docs/Navs-Terminal.Gui.md @@ -0,0 +1,19 @@ +--- +categoryindex: 0 +index: 4 +title: Navs.Terminal.Gui +category: Libraries +--- + +## Navs.Terminal.Gui + +[Terminal.Gui] is a library that allows you to write dynamic terminal interfaces in dotnet. + +We've added support for a quite simple router. + +## WIP + +This document is work in progress, please refer to the [samples directory] for a working example. + +[Terminal.Gui]: https://gui-cs.github.io/Terminal.GuiV2Docs/index.html +[samples directory]: https://github.com/AngelMunoz/Navs/tree/vnext/samples/TGUI diff --git a/docs/Navs.fsx b/docs/Navs.fsx index f2efb1a..3452d56 100644 --- a/docs/Navs.fsx +++ b/docs/Navs.fsx @@ -1,361 +1,364 @@ -(** ---- -categoryindex: 0 -index: 1 -title: Navs -category: Libraries -description: A library for bare bones routing in F# applications -keywords: navigation, routing, url, navigation, navs ---- - -Navs is a... let's say general purpose routing library, it works over a generic value type to enable flexibility. -That means that you can use it most likely anywhere you need to route something. The main use cases are for Desktop Applications. -But there's nothing that can stop you from using it in games or even in console applications. - -## Usage - -To use this library you need to do a couple of things: - -- Define your routes -- Create a router - -From there on, you can use the router to navigate to different parts of your application. - -*) - -(*** hide ***) -#r "nuget: Navs, 1.0.0-beta-008" - -open FSharp.Data.Adaptive -open System -open System.Threading.Tasks -open UrlTemplates.RouteMatcher - -(*** show ***) - -open Navs -open Navs.Router - -module Task = - - let empty = Task.FromResult(()) :> Task - -type Page = { - title: string - content: string - onAction: unit -> Task -} - -let routes = [ - Route.define( - "home", - "/home", - fun _ _ -> { - title = "Home" - content = "Welcome to the home page" - onAction = fun () -> Task.empty - } - ) - Route.define( - "about", - "/about", - fun _ _ -> { - title = "About" - content = "This is the about page" - onAction = fun () -> Task.empty - } - ) -] - -let router = - Router.get( - routes, - fun () -> { - title = "Splash" - content = "Loading..." - onAction = fun () -> Task.empty - } - ) - -(** -At this point we've defined our routes and created a router. The router is ready to be used to navigate to different parts of the application. - -However, the router doesn't navigate anywhere by itself, it requires the user to trigger a navigation event. We can provide a `splash` screen to show while we trigger the first navigation. -*) - -(*** hide ***) -let view = router.Content |> AVal.force - -printfn "Current view: \n\n%A" view -(*** include-output ***) - -task { - let! navigationResult = router.Navigate "/home" - - match navigationResult with - | Ok() -> printfn "Navigated to home" - | Error errors -> printfn "Failed to navigate to home: %A" errors -} -(*** include-output ***) -(*** hide ***) -|> Async.AwaitTask -|> Async.RunSynchronously - -(** - The `router.Navigate` function returns a `Task` that can be awaited to get the result of the navigation. The result is a `Result` type that contains the navigation errors if there were any. - In this case, we're navigating to the `/home` route, and we're printing a message if the navigation was successful or if it failed. - - In order to access the rendered content of the route, you can use the `router.Current` property. This property will contain the rendered content of the current route. - Or if you're using FSharp.Data.Adaptive, you can use the `router.AcaptiveContent` property to get the rendered content as an adaptive value. -*) -let adaptiveContent () = adaptive { - let! view = router.Content - // the view will always be the most up to date view - match view with - | ValueSome view -> - // ... do something with the view ... - printfn "Current view: \n\n%A" view - return () - | ValueNone -> - printfn "No view currently" - // ... do something else ... - return () -} -(** - If you need an observable, you can easily wrap the `router.Content` property in an observable. -*) - -// extend the existing AVal module -module AVal = - let toObservable (value: aval<_>) = - { new IObservable<_> with - member _.Subscribe(observer) = value.AddCallback(observer.OnNext) - } - -// subscribe to the router content -router.Content |> AVal.toObservable - - -(** - > ***NOTE***: If you're coming from C# and you're looking for the observables you can create an extension method in a simialr fashion, but keep in mind that you can use FSharp.Data.Adaptive from C# as well via the [CSharp.Data.Adaptive](https://www.nuget.org/packages/CSharp.Data.Adaptive) package. -*) - -(** -## Async Routes - -Routes can be asynchronous, this is useful when you need to fetch data from -an API or a database before rendering the view. However be mindful that the longer it takes to resolve -these async routes, the longer it will take to render the view. -Creating Async Routes is as simple as returning an `Async` or a `Task` from the route handler. - -For support cancellation, you can pull the cancellation token from the Async computation you're working with. -*) - - -let asyncRoute = - Route.define( - "async", - "/async", - fun _ _ -> async { - let! token = Async.CancellationToken - do! Task.Delay(90, token) |> Async.AwaitTask - - return { - title = "Async" - content = "This is an async route" - onAction = fun () -> Task.empty - } - } - ) - -(** - -Task based routes have access to the cancellation token, so you can support cancellation in your routes. -*) - -let taskRoute = - Route.define( - "task", - "/task", - fun _ (nav: INavigable) token -> task { - do! Task.Delay(90, token) - - return { - title = "Task" - content = "This is a task route" - onAction = - fun () -> task { - do! Task.Delay(90) - nav.Navigate("/home") |> ignore - } - } - } - ) - - -(** -## The RouteContext object - -The `cref:T:Navs.RouteContext` object is a record that contains the following properties: - - -- `Route` - RAW URL that is being activated. -- `UrlInfo` - An object that contains the segments, query and hash of the URL in a string form. -- `UrlMatch` - An object that contains multiple dictionaries with the parameters that were extracted from the URL either from the url parameters the query string or the hash portion of the URL. - -If you wanted to define a route that takes a parameter, and then access that in the route handler, you can do so by using the `RouteContext` object. - -*) - - -Route.define( - "param", - "/param/:id", - fun ctx _ -> - let guid = ctx.urlMatch |> UrlMatch.getFromParams "id" - - { - title = "Param" - content = $"This is a route with a parameter: {guid}" - onAction = fun () -> Task.empty - } -) - -(** -## The `INavigable<'View>` and `IRouter<'View>` interface - -The ``cref:T:Navs.INavigable`1`` is provided so you can perform `Navigate` and `NavigateByname` operations within your view handlers. -Perhaps when a button is clicked, or when a link is clicked, you can use the `INavigable` interface to navigate to a different route. -*) - -Route.define( - "param", - "/param/:id", - fun ctx (nav: INavigable) -> - let guid = ctx.urlMatch |> UrlMatch.getFromParams "id" - - { - title = "Param" - content = $"This is a route with a parameter: {guid}" - onAction = - fun () -> task { - match! nav.Navigate("/home") with - | Ok _ -> () - | Error errors -> printfn "Failed to navigate to home: %A" errors - } - } -) -(** -The UrlMatch property in the context has algo access to the QueryParams and the Hash of the URL that was matched for this route. - -> For more information about extracting parameters from the URL, please refer to the [UrlTemplates Document section](./UrlTemplates.fsx). - -### State and StateSnapshot - -Sometimes it is useful to know if the router is currently navigating to a route or if it's idle. -You can use the ``cref:M:Navs.INavigable`1.State`` and ``cref:M:Navs.INavigable`1.StateSnapshot`` properties to get the current state of the router. - -The `State` property is an adaptive value that will emit the current state of the router. - -The `StateSnapshot` property is a property that will return the current state of the router. - -*) - -let state = router.StateSnapshot - -match state with -| NavigationState.Idle -> printfn "The router is idle" -| NavigationState.Navigating -> printfn "The router is navigating" - -(** -The ``cref:T:Navs.IRouter`1`` interface is reserved to the router object and it provides a few more properties than the navigable interface. -This is manly because the rotuer is likely to be used like a service in the application while the navigable interface is more of a route tied object. - -### Route and RouteSnapshot - -The ``cref:M:Navs.IRouter`1.Route`` property is an adaptive value that represents the actual context used by the active route. -while the ``cref:M:Navs.IRouter`1.RouteSnapshot`` property is a stale version of the previous. - -*) - -let route = router.RouteSnapshot - -(*** hide ***) -printfn "Current route: \n\n%A" route -(*** include-output ***) - -(** - -This property can be useful if you want to make some decisions above the route's handler based on the current route -like showing/hiding a navigation bar or a sidebar. - -## Guards - -Guards are a way to prevent a route from being activated or deactivated. They run before any navigation is performed, the `Can Activate` guards run first followed by the `Can Deactivate` guards. -If any of these guards return a `false` value, the navigation will not initiate at all and the `NavigationError` will be returned from the `router.Navigate` call. - -Guards also have access to the `RouteContext` object, so you can use the `RouteContext` object to make decisions based on the current route. In a similar fashion, remember that -the longer it takes to resolve the guard, the longer it will take to navigate to the route. -*) - -asyncRoute -|> Route.canActivate(fun routeContext _ -> async { - let! token = Async.CancellationToken - do! Task.Delay(90, token) |> Async.AwaitTask - // return Continue to allow the navigation - // return Stop to prevent the navigation - return Continue -}) -|> Route.canDeactivate(fun routeContext _ -> async { - let! token = Async.CancellationToken - do! Task.Delay(90, token) |> Async.AwaitTask - // return Continue to allow the navigation - // return Stop to prevent the navigation - return Stop -}) -|> Route.canActivate(fun routeContext _ -> async { - let! token = Async.CancellationToken - do! Task.Delay(90, token) |> Async.AwaitTask - // CanActivate guards can also "Re-direct" to a different route - return Redirect "/home" -}) - -(** - -Route Can Activate Guards can also be used to redirect to a different route, for example you can protect a route and if the user is not authenticated you can redirect them to the login view. - -> ***Note***: Can Deactivate guards while accept the "Redirect" result, they will not redirect the user to a different route. It will behave the same as if the guard returned "Stop". - -## Caching - -The default behavior of the router is to obtain the view from an internal cache if it's available. However, you can change this behavior by using the `Route.cache` function. -Which will make the router always re-execute the route handler when the route is activated. - -Even if you cache a route there are certain features that will always execute regardless. - -- Parameter parsing and route resolution. -- Check for cancellation at the navigation level. -- Route Guards. - -*) - -asyncRoute |> Route.cache CacheStrategy.NoCache - - - -(** -The rule of thumb for cachign is: - -- [ ] Is the view stateful? -- [ ] Is the user expecting to come back and see the same state in the view? -- [ ] Is the view expensive to render? - -If any of the above checks true, then you should consider caching the view - -- [ ] Is the view state ephemeral and can be discarded when navigating away? -- [ ] Do you want to avoid stale data any time the route is activated? - -If any of the above checks true, then you should consider not caching the view. - -Now keep in mind these are not golden rules written in stone. By default we cache the views, but you can change this behavior in the case it is not suitable for your application. -*) +(** +--- +categoryindex: 0 +index: 1 +title: Navs +category: Libraries +description: A library for bare bones routing in F# applications +keywords: navigation, routing, url, navigation, navs +--- + +Navs is a... let's say general purpose routing library, it works over a generic value type to enable flexibility. +That means that you can use it most likely anywhere you need to route something. The main use cases are for Desktop Applications. +But there's nothing that can stop you from using it in games or even in console applications. + +## Usage + +To use this library you need to do a couple of things: + +- Define your routes +- Create a router + +From there on, you can use the router to navigate to different parts of your application. + +*) + +(*** hide ***) +#r "nuget: Navs, 1.0.0-rc-005" + +open FSharp.Data.Adaptive +open System +open System.Threading.Tasks + +(*** show ***) + +open Navs +open Navs.Router + +type Page = { + title: string + content: string + onAction: unit -> Task +} + +let routes = [ + Route.define( + "home", + "/home", + fun _ _ -> { + title = "Home" + content = "Welcome to the home page" + onAction = fun () -> Task.CompletedTask + } + ) + Route.define( + "about", + "/about", + fun _ _ -> { + title = "About" + content = "This is the about page" + onAction = fun () -> Task.CompletedTask + } + ) +] + +let router = + Router.build( + routes, + fun () -> { + title = "Splash" + content = "Loading..." + onAction = fun () -> Task.CompletedTask + } + ) + +(** +At this point we've defined our routes and created a router. The router is ready to be used to navigate to different parts of the application. + +However, the router doesn't navigate anywhere by itself, it requires the user to trigger a navigation event. We can provide a `splash` screen to show while we trigger the first navigation. +*) + +(*** hide ***) +let view = router.Content |> AVal.force + +printfn "Current view: \n\n%A" view +(*** include-output ***) + +task { + let! navigationResult = router.Navigate "/home" + + match navigationResult with + | Ok() -> printfn "Navigated to home" + | Error errors -> printfn "Failed to navigate to home: %A" errors +} +(*** include-output ***) +(*** hide ***) +|> Async.AwaitTask +|> Async.RunSynchronously + +(** + The `router.Navigate` function returns a `Task` that can be awaited to get the result of the navigation. The result is a `Result` type that contains the navigation errors if there were any. + In this case, we're navigating to the `/home` route, and we're printing a message if the navigation was successful or if it failed. + + In order to access the rendered content of the route, you can use the `router.Current` property. This property will contain the rendered content of the current route. + Or if you're using FSharp.Data.Adaptive, you can use the `router.AcaptiveContent` property to get the rendered content as an adaptive value. +*) +let adaptiveContent () = adaptive { + let! view = router.Content + // the view will always be the most up to date view + match view with + | ValueSome view -> + // ... do something with the view ... + printfn "Current view: \n\n%A" view + return () + | ValueNone -> + printfn "No view currently" + // ... do something else ... + return () +} +(** + If you need an observable, you can easily wrap the `router.Content` property in an observable. +*) + +// extend the existing AVal module +module AVal = + let toObservable (value: aval<_>) = + { new IObservable<_> with + member _.Subscribe(observer) = value.AddCallback(observer.OnNext) + } + +// subscribe to the router content +router.Content |> AVal.toObservable + + +(** + > ***NOTE***: If you're coming from C# and you're looking for the observables you can create an extension method in a simialr fashion, but keep in mind that you can use FSharp.Data.Adaptive from C# as well via the [CSharp.Data.Adaptive](https://www.nuget.org/packages/CSharp.Data.Adaptive) package. +*) + +(** +## Async Routes + +Routes can be asynchronous, this is useful when you need to fetch data from +an API or a database before rendering the view. However be mindful that the longer it takes to resolve +these async routes, the longer it will take to render the view. +Creating Async Routes is as simple as returning an `Async` or a `Task` from the route handler. + +For support cancellation, you can pull the cancellation token from the Async computation you're working with. +*) + + +let asyncRoute = + Route.define( + "async", + "/async", + fun _ _ -> async { + let! token = Async.CancellationToken + do! Task.Delay(90, token) |> Async.AwaitTask + + return { + title = "Async" + content = "This is an async route" + onAction = fun () -> Task.CompletedTask + } + } + ) + +(** + +Task based routes have access to the cancellation token, so you can support cancellation in your routes. +*) + +let taskRoute = + Route.define( + "task", + "/task", + fun _ (nav: INavigable) token -> task { + do! Task.Delay(90, token) + + return { + title = "Task" + content = "This is a task route" + onAction = + fun () -> task { + do! Task.Delay(90) + nav.Navigate("/home") |> ignore + } + } + } + ) + + +(** +## The RouteContext object + +The `cref:T:Navs.RouteContext` object is a record that contains the following properties: + + +- `Route` - RAW URL that is being activated. +- `UrlInfo` - An object that contains the segments, query and hash of the URL in a string form. +- `UrlMatch` - An object that contains multiple dictionaries with the parameters that were extracted from the URL either from the url parameters the query string or the hash portion of the URL. + +If you wanted to define a route that takes a parameter, and then access that in the route handler, you can do so by using the `RouteContext` object. + +*) + + +Route.define( + "param", + "/param/:id", + fun ctx _ -> + let guid = + // or ctx.getParam "id" if that's your style + RouteContext.getParam "id" ctx + |> ValueOption.map(_.ToString()) + |> ValueOption.defaultValue "No Guid Supplied" + + { + title = "Param" + content = $"This is a route with a parameter: {guid}" + onAction = fun () -> Task.CompletedTask + } +) + +(** +## The `INavigable<'View>` and `IRouter<'View>` interface + +The ``cref:T:Navs.INavigable`1`` is provided so you can perform `Navigate` and `NavigateByname` operations within your view handlers. +Perhaps when a button is clicked, or when a link is clicked, you can use the `INavigable` interface to navigate to a different route. +*) + +Route.define( + "param", + "/param/:id", + fun ctx (nav: INavigable) -> + let guid = + ctx.getParam("id") + |> ValueOption.map(_.ToString()) + |> ValueOption.defaultValue "No Guid Supplied" + + { + title = "Param" + content = $"This is a route with a parameter: {guid}" + onAction = + fun () -> task { + match! nav.Navigate("/home") with + | Ok _ -> () + | Error errors -> printfn "Failed to navigate to home: %A" errors + } + } +) +(** +The UrlMatch property in the context has algo access to the QueryParams and the Hash of the URL that was matched for this route. + +> For more information about extracting parameters from the URL, please refer to the [UrlTemplates Document section](./UrlTemplates.fsx). + +### State and StateSnapshot + +Sometimes it is useful to know if the router is currently navigating to a route or if it's idle. +You can use the ``cref:M:Navs.INavigable`1.State`` and ``cref:M:Navs.INavigable`1.StateSnapshot`` properties to get the current state of the router. + +The `State` property is an adaptive value that will emit the current state of the router. + +The `StateSnapshot` property is a property that will return the current state of the router. + +*) + +let state = router.StateSnapshot + +match state with +| NavigationState.Idle -> printfn "The router is idle" +| NavigationState.Navigating -> printfn "The router is navigating" + +(** +The ``cref:T:Navs.IRouter`1`` interface is reserved to the router object and it provides a few more properties than the navigable interface. +This is manly because the rotuer is likely to be used like a service in the application while the navigable interface is more of a route tied object. + +### Route and RouteSnapshot + +The ``cref:M:Navs.IRouter`1.Route`` property is an adaptive value that represents the actual context used by the active route. +while the ``cref:M:Navs.IRouter`1.RouteSnapshot`` property is a stale version of the previous. + +*) + +let route = router.RouteSnapshot + +(*** hide ***) +printfn "Current route: \n\n%A" route +(*** include-output ***) + +(** + +This property can be useful if you want to make some decisions above the route's handler based on the current route +like showing/hiding a navigation bar or a sidebar. + +## Guards + +Guards are a way to prevent a route from being activated or deactivated. They run before any navigation is performed, the `Can Activate` guards run first followed by the `Can Deactivate` guards. +If any of these guards return a `false` value, the navigation will not initiate at all and the `NavigationError` will be returned from the `router.Navigate` call. + +Guards also have access to the `RouteContext` object, so you can use the `RouteContext` object to make decisions based on the current route. In a similar fashion, remember that +the longer it takes to resolve the guard, the longer it will take to navigate to the route. +*) + +asyncRoute +|> Route.canActivateAsync(fun routeContext _ -> async { + let! token = Async.CancellationToken + do! Task.Delay(90, token) |> Async.AwaitTask + // return Continue to allow the navigation + // return Stop to prevent the navigation + return Continue +}) +|> Route.canDeactivateAsync(fun routeContext _ -> async { + let! token = Async.CancellationToken + do! Task.Delay(90, token) |> Async.AwaitTask + // return Continue to allow the navigation + // return Stop to prevent the navigation + return Stop +}) +|> Route.canActivateAsync(fun routeContext _ -> async { + let! token = Async.CancellationToken + do! Task.Delay(90, token) |> Async.AwaitTask + // CanActivate guards can also "Re-direct" to a different route + return Redirect "/home" +}) + +(** + +Route Can Activate Guards can also be used to redirect to a different route, for example you can protect a route and if the user is not authenticated you can redirect them to the login view. + +> ***Note***: Can Deactivate guards while accept the "Redirect" result, they will not redirect the user to a different route. It will behave the same as if the guard returned "Stop". + +## Caching + +The default behavior of the router is to obtain the view from an internal cache if it's available. However, you can change this behavior by using the `Route.cache` function. +Which will make the router always re-execute the route handler when the route is activated. + +Even if you cache a route there are certain features that will always execute regardless. + +- Parameter parsing and route resolution. +- Check for cancellation at the navigation level. +- Route Guards. + +*) + +asyncRoute |> Route.cache CacheStrategy.NoCache + + + +(** +The rule of thumb for cachign is: + +- [ ] Is the view stateful? +- [ ] Is the user expecting to come back and see the same state in the view? +- [ ] Is the view expensive to render? + +If any of the above checks true, then you should consider caching the view + +- [ ] Is the view state ephemeral and can be discarded when navigating away? +- [ ] Do you want to avoid stale data any time the route is activated? +- [ ] Do you need to dispose of resources when the route is deactivated? + +If any of the above checks true, then you should consider not caching the view. + +Now keep in mind these are not golden rules written in stone. By default we cache the views, but you can change this behavior in the case it is not suitable for your application. +*) diff --git a/docs/Navs/Adapters.md b/docs/Navs/Adapters.md index 2663ad0..7844bf9 100644 --- a/docs/Navs/Adapters.md +++ b/docs/Navs/Adapters.md @@ -7,7 +7,7 @@ category: Navs ## Creating an adapter. [hide] - #r "nuget: Navs, 1.0.0-beta-008" + #r "nuget: Navs, 1.0.0-rc-005" Sometimes you may want to create a custom adapter when you know the concrete types (or the interface) that you're targeting with your router and your definitions. This is a guide on how to create an adapter for a custom type. @@ -17,9 +17,8 @@ Let's take a look at how we implemented the Plain Avalonia adapter. Normal Avalo open Navs.Router type AvaloniaRouter(routes, [] ?splash: Func) = - let router = Router.get(routes, ?splash = splash) - let splash = splash |> Option.map(fun f -> fun () -> f.Invoke()) - Router.get(routes, ?splash = splash) + let splash = splash |> Option.map(fun f -> fun () -> f.Invoke()) + let router = Router.build(routes, ?splash = splash) interface IRouter with @@ -71,7 +70,7 @@ Once that is done, we can convert our route definitions to the custom type and u From - let router = Router.get([ + let router = Router.build([ Route.define("Home", "/", fun ctx _ -> async { do! Async.Sleep 90 return UserControl() diff --git a/docs/Navs/DotnetUsage.md b/docs/Navs/DotnetUsage.md index 77caca1..4a8a25c 100644 --- a/docs/Navs/DotnetUsage.md +++ b/docs/Navs/DotnetUsage.md @@ -33,7 +33,7 @@ RouteDefinition[] routes = [ }) ]; -IRouter router = Router.Get(routes); +IRouter router = Router.Build(routes); ``` From there on, you can use the router as you would in F#. for more information visit the [Navs](../Navs.fsx) general documentation. diff --git a/docs/UrlTemplates.fsx b/docs/UrlTemplates.fsx index b16b04d..265fef2 100644 --- a/docs/UrlTemplates.fsx +++ b/docs/UrlTemplates.fsx @@ -1,198 +1,198 @@ -(** ---- -categoryindex: 0 -index: 1 -title: UrlTemplates -category: Libraries -description: A library for creating and parsing URL templates. -keywords: url, template, url-parsing, validation ---- - -This library specializes itself in parsing URL-like strings into structured objects. It is used by [Navs](./Navs.fsx) to parse navigable URLs and URL templates to find if they match. -*) - -(*** hide ***) -#r "nuget: UrlTemplates, 1.0.0-beta-008" - -open System -(** -## Usage - -For most use cases, you will want to use the existing `RouteMatcher` module. This module provides a single function `matchStrings` that takes a URL template and a URL and returns a `Result` type with the parsed URL template, the parsed URL, and the matched parameters. -*) - -open UrlTemplates.RouteMatcher - -let template = "/api/v1/profiles/:id?activity#hash" - -let url = $"/api/v1/profiles/{Guid.NewGuid()}?activity=false#profile" - -let urlTemplate, urlInfo, urlMatch = - match RouteMatcher.matchStrings template url with - | Ok(urlTemplate, urlInfo, urlMatch) -> urlTemplate, urlInfo, urlMatch - | Error errors -> failwithf "Failed to match URL: %A" errors - -(** -The `urlTemplate` is the parsed URL template, the `urlInfo` is the parsed URL, and the `urlMatch` is the matched parameters. - -For example the contents of the URL template can be inspected and this information is what is ultimately used to determine if a URL matches a templated string. -*) - -(*** hide ***) -printfn "\n\n%A\n\n" urlTemplate - -(*** include-output ***) - -(** - For the above case it means that URLs will be matched as long as they contain the `/api/v1/profiles/` prefix, followed by a GUID, and an optional query parameter `activity` that is a boolean. The URL can also contain a hash. -*) - - -(** - For the case of the `urlInfo` it contains the parsed URL as a plain string, it doesn't contain any information about the matched parameters. -*) - -(*** hide ***) -printfn "\n\n%A\n\n" urlInfo -(*** include-output ***) - -(** - Finally, the `urlMatch` contains the matched parameters if they were supplied in the URL. - For the above case, it will contain the matched GUID and the matched boolean. -*) - -(*** hide ***) -printfn - "Segments and Params: %s\n" - (urlMatch.Params - |> Seq.map(fun (KeyValue(k, v)) -> $"%A{v}") - |> String.concat "\n - ") - -printfn - "Query Params: %s\n" - (urlMatch.QueryParams - |> Seq.map(fun (KeyValue(k, v)) -> $"{k}=%A{v}") - |> String.concat "\n - ") - -printfn "Hash: %A" urlMatch.Hash -(*** include-output ***) - - -(** -## Providing Types for Parameters - -Given that parameters can be matched against a type, it is possible to provide some of the Well known types included in `cref:T:UrlTemplates.UrlTemplate.TypedParam` -The general syntax is `paramName` where `type` is one of the following: - -- `int` -- `float` -- `bool` -- `guid` -- `long` -- `decimal` - -For parameters where the name is not specified, the automatic type will be `string`. - -Names must be alphanumeric strings and may contain `-` and `_` characters. - -### Segment params - -To specify a type for a segment parameter, use the following syntax: - -- `/:paramName` - -For example: - -- `/api/v1/users/:id/items/:itemId` - -Please note that every segment parameter is required even the trailing one. - -### Query params - -To specify a type for a query parameter, use the following syntax: - -- `?paramName` - for optional query parameters -- `?paramName!` - for required query parameters - -For example: - -- `/?name!&age` - -In this case, the `name` query parameter is required and must be a string, and the `age` query parameter is optional but if suipplied it must be an integer. - -### Hash - -The hash is a ValueOption string and it is specified as follows: - -- `#hash` - -For example: - -- `/docs/functions#callbacks` - - -## Extracting Parameters from a Matched URL - -The `UrlMatch` type provides a set of functions to extract parameters from a matched URL. - -*) - - -(*** hide ***) - -open UrlTemplates.RouteMatcher - -(*** show ***) - -let tplDefinition = "/api?name&age&statuses" -let targetUrl = "/api?name=john&age=30&statuses=active&statuses=inactive" - -let _, _, matchedUrl = - RouteMatcher.matchStrings tplDefinition targetUrl - |> Result.defaultWith(fun e -> failwithf "Expected Ok, got Error %O" e) - - -(** - -Normally query parameters are stored in string, object read only dictionaries however we offer a few utilities to extract them into a more useful format. -for de F# folks you can use the following functions: -*) - -let name = matchedUrl |> UrlMatch.getFromParams "name" - -let age = matchedUrl |> UrlMatch.getFromParams "age" - -let values = matchedUrl |> UrlMatch.getParamSeqFromQuery "statuses" - -let statuses = - values |> ValueOption.defaultWith(fun _ -> failwith "Expected a value") - -(*** hide ***) - -printfn $"%A{statuses}" -(*** include-output ***) - - -(** -For the non F# folks extension methods are also provided - -```csharp -var name = urlMatch.GetFromParams("name"); - -var age = urlMatch.GetFromParams("age"); - -var values = urlMatch.GetParamSeqFromQuery("statuses"); - -if (values.IsNone) -{ - throw new Exception("Expected a value"); -} - -Console.WriteLine($"{values.Value[0]}, {values.Value[1]}"); -// inactive, active -``` - -> ***Note:*** The ``cref:M:UrlTemplates.RouteMatcher.UrlMatchModule.getParamSeqFromQuery`1`` function and its extension method counterpart -> does not guarantee that the values are in the same order as they were in the URL. - -*) +(** +--- +categoryindex: 0 +index: 1 +title: UrlTemplates +category: Libraries +description: A library for creating and parsing URL templates. +keywords: url, template, url-parsing, validation +--- + +This library specializes itself in parsing URL-like strings into structured objects. It is used by [Navs](./Navs.fsx) to parse navigable URLs and URL templates to find if they match. +*) + +(*** hide ***) +#r "nuget: UrlTemplates, 1.0.0-rc-005" + +open System +(** +## Usage + +For most use cases, you will want to use the existing `RouteMatcher` module. This module provides a single function `matchStrings` that takes a URL template and a URL and returns a `Result` type with the parsed URL template, the parsed URL, and the matched parameters. +*) + +open UrlTemplates.RouteMatcher + +let template = "/api/v1/profiles/:id?activity#hash" + +let url = $"/api/v1/profiles/{Guid.NewGuid()}?activity=false#profile" + +let urlTemplate, urlInfo, urlMatch = + match RouteMatcher.matchStrings template url with + | Ok(urlTemplate, urlInfo, urlMatch) -> urlTemplate, urlInfo, urlMatch + | Error errors -> failwithf "Failed to match URL: %A" errors + +(** +The `urlTemplate` is the parsed URL template, the `urlInfo` is the parsed URL, and the `urlMatch` is the matched parameters. + +For example the contents of the URL template can be inspected and this information is what is ultimately used to determine if a URL matches a templated string. +*) + +(*** hide ***) +printfn "\n\n%A\n\n" urlTemplate + +(*** include-output ***) + +(** + For the above case it means that URLs will be matched as long as they contain the `/api/v1/profiles/` prefix, followed by a GUID, and an optional query parameter `activity` that is a boolean. The URL can also contain a hash. +*) + + +(** + For the case of the `urlInfo` it contains the parsed URL as a plain string, it doesn't contain any information about the matched parameters. +*) + +(*** hide ***) +printfn "\n\n%A\n\n" urlInfo +(*** include-output ***) + +(** + Finally, the `urlMatch` contains the matched parameters if they were supplied in the URL. + For the above case, it will contain the matched GUID and the matched boolean. +*) + +(*** hide ***) +printfn + "Segments and Params: %s\n" + (urlMatch.Params + |> Seq.map(fun (KeyValue(k, v)) -> $"%A{v}") + |> String.concat "\n - ") + +printfn + "Query Params: %s\n" + (urlMatch.QueryParams + |> Seq.map(fun (KeyValue(k, v)) -> $"{k}=%A{v}") + |> String.concat "\n - ") + +printfn "Hash: %A" urlMatch.Hash +(*** include-output ***) + + +(** +## Providing Types for Parameters + +Given that parameters can be matched against a type, it is possible to provide some of the Well known types included in `cref:T:UrlTemplates.UrlTemplate.TypedParam` +The general syntax is `paramName` where `type` is one of the following: + +- `int` +- `float` +- `bool` +- `guid` +- `long` +- `decimal` + +For parameters where the name is not specified, the automatic type will be `string`. + +Names must be alphanumeric strings and may contain `-` and `_` characters. + +### Segment params + +To specify a type for a segment parameter, use the following syntax: + +- `/:paramName` + +For example: + +- `/api/v1/users/:id/items/:itemId` + +Please note that every segment parameter is required even the trailing one. + +### Query params + +To specify a type for a query parameter, use the following syntax: + +- `?paramName` - for optional query parameters +- `?paramName!` - for required query parameters + +For example: + +- `/?name!&age` + +In this case, the `name` query parameter is required and must be a string, and the `age` query parameter is optional but if suipplied it must be an integer. + +### Hash + +The hash is a ValueOption string and it is specified as follows: + +- `#hash` + +For example: + +- `/docs/functions#callbacks` + + +## Extracting Parameters from a Matched URL + +The `UrlMatch` type provides a set of functions to extract parameters from a matched URL. + +*) + + +(*** hide ***) + +open UrlTemplates.RouteMatcher + +(*** show ***) + +let tplDefinition = "/api?name&age&statuses" +let targetUrl = "/api?name=john&age=30&statuses=active&statuses=inactive" + +let _, _, matchedUrl = + RouteMatcher.matchStrings tplDefinition targetUrl + |> Result.defaultWith(fun e -> failwithf "Expected Ok, got Error %O" e) + + +(** + +Normally query parameters are stored in string, object read only dictionaries however we offer a few utilities to extract them into a more useful format. +for de F# folks you can use the following functions: +*) + +let name = matchedUrl |> UrlMatch.getFromParams "name" + +let age = matchedUrl |> UrlMatch.getFromParams "age" + +let values = matchedUrl |> UrlMatch.getParamSeqFromQuery "statuses" + +let statuses = + values |> ValueOption.defaultWith(fun _ -> failwith "Expected a value") + +(*** hide ***) + +printfn $"%A{statuses}" +(*** include-output ***) + + +(** +For the non F# folks extension methods are also provided + +```csharp +var name = urlMatch.GetFromParams("name"); + +var age = urlMatch.GetFromParams("age"); + +var values = urlMatch.GetParamSeqFromQuery("statuses"); + +if (values.IsNone) +{ + throw new Exception("Expected a value"); +} + +Console.WriteLine($"{values.Value[0]}, {values.Value[1]}"); +// inactive, active +``` + +> ***Note:*** The ``cref:M:UrlTemplates.RouteMatcher.UrlMatchModule.getParamSeqFromQuery`1`` function and its extension method counterpart +> does not guarantee that the values are in the same order as they were in the URL. + +*) diff --git a/docs/index.md b/docs/index.md index 68dc467..0d5c775 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,14 +32,14 @@ let routes = [ fun context _ -> async { do! Async.Sleep(90) return - match context.UrlMatch.Params.TryGetValue "id" with - | true, id -> sprintf "Home %A" id - | false, _ -> "Guid No GUID" + match context.getParam "id" with + | ValueSome id -> sprintf "Home %A" id + | ValueNone -> "Guid No GUID" } ) ] -let router = Router.get(routes) +let router = Router.build(routes) router.Content.AddCallback(fun content -> printfn $"%A{content}") @@ -73,7 +73,7 @@ let routes = [ do! Async.Sleep(90) return // extract parameters from the URL - match context.urlMatch |> UrlMatch.getFromParams "id" with + match context.getParam "id" with | ValueSome id -> TextBlock().text(sprintf "Home %A" id) | ValueNone -> TextBlock().text("Guid No GUID") } @@ -143,7 +143,7 @@ let routes = [ fun context _ -> async { return TextBlock.create [ - match context.urlMatch |> UrlMatch.getFromParams "id" with + match context.getParam "id" with | ValueSome id -> TextBlock.text $"Visited: {id}" | ValueNone -> TextBlock.text "Guid No GUID" ] diff --git a/samples/CSharpToo/CSharpToo.csproj b/samples/CSharpToo/CSharpToo.csproj index be94fd5..29252e9 100644 --- a/samples/CSharpToo/CSharpToo.csproj +++ b/samples/CSharpToo/CSharpToo.csproj @@ -2,18 +2,17 @@ Exe - net9.0 + net10.0 enable enable - - - - - - + + + + + diff --git a/samples/CSharpToo/Program.cs b/samples/CSharpToo/Program.cs index 0dca828..cfa94ac 100644 --- a/samples/CSharpToo/Program.cs +++ b/samples/CSharpToo/Program.cs @@ -22,33 +22,34 @@ static IEnumerable> GetRoutes() => [ Route.Define("home", "/", (_ , _) => { var (count, setCount) = UseState(0); - return StackPanel() + var content = new StackPanel() .Spacing(8) .Children( - TextBlock().Text("Home"), - TextBlock().Text(count.Map(value => $"Count: {value}").ToBinding()), - Button().Content("Increment").OnClickHandler((_, _) => setCount(count => count + 1)), - Button().Content("Decrement").OnClickHandler((_, _) => setCount(count => count - 1)), - Button().Content("Reset").OnClickHandler((_, _) => setCount(_ => 0)) + new TextBlock().Text("Home"), + new TextBlock().Text(count.Map(value => $"Count: {value}").ToBinding()), + new Button().Content("Increment").OnClickHandler((_, _) => setCount(count => count + 1)), + new Button().Content("Decrement").OnClickHandler((_, _) => setCount(count => count - 1)), + new Button().Content("Reset").OnClickHandler((_, _) => setCount(_ => 0)) ); + return content; }), Route.Define("about", "/about", (_ , _)=> { var text = new ChangeableValue(""); - return StackPanel() + return new StackPanel() .Spacing(8) .Children( - TextBlock().Text("About"), - TextBlock().Text("This is a simple Avalonia app with a router, It uses Navs for routing and Adaptive Data for state management."), - TextBlock().Text( + new TextBlock().Text("About"), + new TextBlock().Text("This is a simple Avalonia app with a router, It uses Navs for routing and Adaptive Data for state management."), + new TextBlock().Text( text.Map(value => { if (string.IsNullOrWhiteSpace(value)) { return "Type something!"; } return $"You typed: {value}"; }) .ToBinding() ), - TextBox() - .Watermark("Type here!") + new TextBox() + .PlaceholderText("Type here!") .OnTextChangedHandler((source,args) =>{ if (source.Text is null) { return; } text.SetValue(text => source.Text); @@ -56,17 +57,17 @@ static IEnumerable> GetRoutes() => [ ); }), - Route.Define("by-name", "/by-name?id", async (ctx, nav, token) => { + Route.Define("by-name", "/by-name?id", async (ctx, nav, token) => { // Simulate a fetch or something await Task.Delay(80, token); var guid = UrlMatchModule.getFromParams("id", ctx.urlMatch); if (guid.IsValueSome && guid.Value is Guid id) { - return TextBlock().Text($"By Name: {id}"); + return new TextBlock().Text($"By Name: {id}"); } - return StackPanel().Children( - TextBlock().Text($"No ID Found!"), - Button().Content("Visit one with Id") + return new StackPanel().Children( + new TextBlock().Text($"No ID Found!"), + new Button().Content("Visit one with Id") .OnClickHandler((_, _) => { nav.Navigate("/by-name?id=" + Guid.NewGuid()); }) @@ -79,24 +80,24 @@ static Window GetWindow() { IRouter router = new AvaloniaRouter(GetRoutes()); - return Window().Title("Hello World!").Content( + return new Window().Title("Hello World!").Content( StackPanel().Children( - Button().Content("Home").OnClickHandler((_, _) => NavigateTo("/", router)), - Button().Content("About").OnClickHandler((_, _) => NavigateTo("/about", router)), - Button() + new Button().Content("Home").OnClickHandler((_, _) => NavigateTo("/", router)), + new Button().Content("About").OnClickHandler((_, _) => NavigateTo("/about", router)), + new Button() .Content("Navigate By Name") .OnClickHandler((_, _) => NavigateByName("by-name", router, new Dictionary { { "id", Guid.NewGuid() } })), - Button() + new Button() .Content("Navigate By Name Missing Param") .OnClickHandler((_, _) => NavigateByName("by-name", router)), - RouterOutlet().Router(router) + new RouterOutlet().Router(router) ) ); } static async void NavigateByName(string name, IRouter router, Dictionary? routeParams = null) { - var result = await router.NavigateByName("by-name", routeParams); + var result = await router.NavigateByName("by-name", routeParams ?? []); if (result.IsError) { Console.WriteLine($"Error: {result.ErrorValue}"); } else diff --git a/samples/FuncUI/FuncUI.fsproj b/samples/FuncUI/FuncUI.fsproj index 2d12af8..ca0f136 100644 --- a/samples/FuncUI/FuncUI.fsproj +++ b/samples/FuncUI/FuncUI.fsproj @@ -2,7 +2,8 @@ Exe - net9.0 + net10.0 + false @@ -10,10 +11,11 @@ - - - - + + + + + diff --git a/samples/FuncUI/Program.fs b/samples/FuncUI/Program.fs index a876195..b095916 100644 --- a/samples/FuncUI/Program.fs +++ b/samples/FuncUI/Program.fs @@ -1,170 +1,175 @@ -open System -open Avalonia -open Avalonia.Controls -open Avalonia.Controls.ApplicationLifetimes - -open Avalonia.FuncUI -open Avalonia.FuncUI.Hosts -open Avalonia.FuncUI.DSL - -open FSharp.Data.Adaptive - -open Navs -open Navs.FuncUI -open Avalonia.Themes.Fluent -open Avalonia.FuncUI.Types - -// use external to FuncUI shared state -let sharedState = cval 0 - -let navbar (router: IRouter) : IView = - StackPanel.create [ - StackPanel.dock Dock.Top - StackPanel.orientation Layout.Orientation.Horizontal - StackPanel.children [ - Button.create [ - Button.content "Books" - Button.onClick(fun _ -> - router.Navigate "/books" |> ignore - // example "external updates" - transact(fun _ -> sharedState.Value <- sharedState.Value + 1) - ) - ] - Button.create [ - Button.content "Guid" - Button.onClick(fun _ -> - router.Navigate $"/{Guid.NewGuid()}" |> ignore - transact(fun _ -> sharedState.Value <- sharedState.Value + 1) - ) - ] - Button.create [ - Button.content "Counter" - Button.onClick(fun _ -> - router.Navigate $"/counter" |> ignore - transact(fun _ -> sharedState.Value <- sharedState.Value + 1) - ) - ] - Button.create [ - Button.content "Readable counter" - Button.onClick(fun _ -> - router.Navigate $"/read-counter" |> ignore - transact(fun _ -> sharedState.Value <- sharedState.Value + 1) - ) - ] - ] - ] - - -let routes = [ - Route.define( - "books", - "/books", - (fun _ _ -> TextBlock.create [ TextBlock.text "Books" ]) - ) - Route.define( - "guid", - "/:id", - fun context _ -> async { - return - TextBlock.create [ - match context.urlMatch.Params.TryGetValue "id" with - | true, id -> TextBlock.text $"Visited: {id}" - | false, _ -> TextBlock.text "Guid No GUID" - ] - } - ) - |> Route.cache NoCache - Route.define( - "read-counter", - "/read-counter", - fun _ _ -> - Component.create( - "read-counter", - fun ctx -> - // consume adaptive data in the standard FuncUI way - let counter = ctx.useAVal sharedState - - StackPanel.create [ - StackPanel.children [ - TextBlock.create [ TextBlock.text(counter.Current |> string) ] - ] - ] - ) - ) - Route.define( - "counter", - "/counter", - fun _ _ -> - Component.create( - "counter", - fun ctx -> - let counter = ctx.useCval sharedState - - let increment = - Button.create [ - Button.content "Increment" - Button.onClick(fun _ -> - // values can also be updated using the standard FuncUI way - counter.Set(counter.Current + 1) |> ignore - ) - ] - - let decrement = - Button.create [ - Button.content "Decrement" - Button.onClick(fun _ -> - counter.Set(counter.Current + 1) |> ignore - ) - ] - - StackPanel.create [ - StackPanel.children [ - increment - decrement - TextBlock.create [ TextBlock.text(counter.Current |> string) ] - ] - ] - ) - ) -] - -let appContent (router: FuncUIRouter, navbar: FuncUIRouter -> IView) = - Component(fun ctx -> - let noContent = TextBlock.create [ TextBlock.text "No content" ] - - DockPanel.create [ - DockPanel.lastChildFill true - DockPanel.children [ - navbar router - RouterOutlet.create(router, noContent) - ] - ] - ) - - -type AppWindow(router: FuncUIRouter) as this = - inherit HostWindow() - - do this.Content <- appContent(router, navbar) - - -type App() = - inherit Application() - - override this.Initialize() = this.Styles.Add(FluentTheme()) - - override this.OnFrameworkInitializationCompleted() = - let router = FuncUIRouter routes - let window = AppWindow router - - match this.ApplicationLifetime with - | :? IClassicDesktopStyleApplicationLifetime as desktop -> - desktop.MainWindow <- window :> Window - | _ -> () - - -AppBuilder - .Configure() - .UsePlatformDetect() - .StartWithClassicDesktopLifetime(System.Environment.GetCommandLineArgs()) -|> ignore +open System +open Avalonia +open Avalonia.Controls +open Avalonia.Controls.ApplicationLifetimes + +open Avalonia.FuncUI +open Avalonia.FuncUI.Hosts +open Avalonia.FuncUI.DSL + +open FSharp.Data.Adaptive + +open Navs +open Navs.FuncUI +open Avalonia.Themes.Fluent +open Avalonia.FuncUI.Types + +// use external to FuncUI shared state +let sharedState = cval 0 + +type ChangeableValue<'T> with + member this.Set(value: 'T) = transact(fun _ -> this.Value <- value) + + member this.Current = this.Value + +let navbar (router: IRouter) : IView = + StackPanel.create [ + StackPanel.dock Dock.Top + StackPanel.orientation Layout.Orientation.Horizontal + StackPanel.children [ + Button.create [ + Button.content "Books" + Button.onClick(fun _ -> + router.Navigate "/books" |> ignore + // example "external updates" + sharedState.Set(sharedState.Value + 1) + ) + ] + Button.create [ + Button.content "Guid" + Button.onClick(fun _ -> + router.Navigate $"/{Guid.NewGuid()}" |> ignore + sharedState.Set(sharedState.Value + 1) + ) + ] + Button.create [ + Button.content "Counter" + Button.onClick(fun _ -> + router.Navigate $"/counter" |> ignore + sharedState.Set(sharedState.Value + 1) + ) + ] + Button.create [ + Button.content "Readable counter" + Button.onClick(fun _ -> + router.Navigate $"/read-counter" |> ignore + sharedState.Set(sharedState.Value + 1) + ) + ] + ] + ] + + +let routes = [ + Route.define( + "books", + "/books", + (fun _ _ -> TextBlock.create [ TextBlock.text "Books" ]) + ) + Route.define( + "guid", + "/:id", + fun context _ -> async { + return + TextBlock.create [ + match context.urlMatch.Params.TryGetValue "id" with + | true, id -> TextBlock.text $"Visited: {id}" + | false, _ -> TextBlock.text "Guid No GUID" + ] + } + ) + |> Route.cache NoCache + Route.define( + "read-counter", + "/read-counter", + fun _ _ -> + Component.create( + "read-counter", + fun ctx -> + // consume adaptive data in the standard FuncUI way + let counter = ctx.useAVal sharedState + + StackPanel.create [ + StackPanel.children [ + TextBlock.create [ TextBlock.text(counter.Current |> string) ] + ] + ] + ) + ) + Route.define( + "counter", + "/counter", + fun _ _ -> + Component.create( + "counter", + fun ctx -> + let counter = ctx.useCval sharedState + + let increment = + Button.create [ + Button.content "Increment" + Button.onClick(fun _ -> + // values can also be updated using the standard FuncUI way + counter.Set(counter.Current + 1) |> ignore + ) + ] + + let decrement = + Button.create [ + Button.content "Decrement" + Button.onClick(fun _ -> + counter.Set(counter.Current - 1) |> ignore + ) + ] + + StackPanel.create [ + StackPanel.children [ + increment + decrement + TextBlock.create [ TextBlock.text(counter.Current |> string) ] + ] + ] + ) + ) +] + +let appContent (router: FuncUIRouter, navbar: FuncUIRouter -> IView) = + Component(fun ctx -> + let noContent = TextBlock.create [ TextBlock.text "No content" ] + + DockPanel.create [ + DockPanel.lastChildFill true + DockPanel.children [ + navbar router + RouterOutlet.create(router, noContent = noContent) + ] + ] + ) + + +type AppWindow(router: FuncUIRouter) as this = + inherit HostWindow() + + do this.Content <- appContent(router, navbar) + + +type App() = + inherit Application() + + override this.Initialize() = this.Styles.Add(FluentTheme()) + + override this.OnFrameworkInitializationCompleted() = + let router = FuncUIRouter routes + let window = AppWindow router + + match this.ApplicationLifetime with + | :? IClassicDesktopStyleApplicationLifetime as desktop -> + desktop.MainWindow <- window :> Window + | _ -> () + + +AppBuilder + .Configure() + .UsePlatformDetect() + .StartWithClassicDesktopLifetime(System.Environment.GetCommandLineArgs()) +|> ignore diff --git a/samples/NxuiDeclarative/NxuiDeclarative.fsproj b/samples/NxuiDeclarative/NxuiDeclarative.fsproj new file mode 100644 index 0000000..7c7d7b0 --- /dev/null +++ b/samples/NxuiDeclarative/NxuiDeclarative.fsproj @@ -0,0 +1,23 @@ + + + Exe + net10.0 + false + + + + + + + + + + + + + + + + + + diff --git a/samples/NxuiDeclarative/Pages/Books.fs b/samples/NxuiDeclarative/Pages/Books.fs new file mode 100644 index 0000000..c34005f --- /dev/null +++ b/samples/NxuiDeclarative/Pages/Books.fs @@ -0,0 +1,99 @@ +module Pages.Books + +open System +open System.Threading +open System.Threading.Tasks +open IcedTasks +open NXUI.Extensions +open Avalonia.Controls +open Avalonia.Controls.Templates +open FSharp.Data.Adaptive +open IcedTasks +open Navs +open Navs.Avalonia + +let private bookTitles = [ + "StarCraft: Liberty's Crusade" + "StarCraft: The Queen of Blades" + "StarCraft: Ghost - Nova" + "Halo: The Fall of Reach" + "Halo: The Flood" +] + +type private ViewState = + | Loading + | Idle + +let private loadBooks (setState, setBooks) (query: string voption) = + asyncEx { + setState(fun _ -> Loading) + + let foundBooks = + match query with + | ValueSome title -> + let books = + // try a fuzzy match search + bookTitles + |> List.filter(fun t -> + t.IndexOf(title, StringComparison.InvariantCultureIgnoreCase) >= 0 + ) + + if List.isEmpty books then + [ $"No books found for '{title}'" ] + else + books + | ValueNone -> bookTitles + // Simulate a delay to mimic fetching data + do! Async.Sleep(1000) + setBooks(fun _ -> foundBooks) + setState(fun _ -> Idle) + } + |> Async.StartImmediate + +let private bookTemplate = + FuncDataTemplate(fun title _ -> + TextBlock() + .Text(title) + .FontSize(16.) + .Margin(8.) + .HorizontalAlignmentCenter() + .VerticalAlignmentCenter() + ) + +let private Header () = + TextBlock() + .Text("Books") + .FontSize(24.) + .Margin(8.) + .HorizontalAlignmentCenter() + .VerticalAlignmentCenter() + +let private PageContent (state, books) = + let content = + state + |> AVal.map(fun state -> + match state with + | Loading -> TextBlock().Text("Loading books...") :> Control + | Idle -> + ItemsControl() + .ItemsSource(books |> AVal.toBinding) + .ItemTemplate(bookTemplate) + ) + |> AVal.toBinding + + UserControl() + .Content(content) + .HorizontalAlignmentStretch() + .VerticalAlignmentStretch() + +let view: AsyncView = + fun context _ -> asyncEx { + let query = context.getParam "title" + let state, setState = AVal.useState Loading + let books, setBooks = AVal.useState [] + let loadBooks = loadBooks(setState, setBooks) + + loadBooks query + + return StackPanel().Children(Header(), PageContent(state, books)) :> Control + } diff --git a/samples/NxuiDeclarative/Pages/Counter.fs b/samples/NxuiDeclarative/Pages/Counter.fs new file mode 100644 index 0000000..bc16d57 --- /dev/null +++ b/samples/NxuiDeclarative/Pages/Counter.fs @@ -0,0 +1,34 @@ +module Pages.Counter + +open System +open System.Threading +open System.Threading.Tasks +open IcedTasks +open NXUI.Extensions +open Avalonia.Controls +open Avalonia.Controls.Templates +open FSharp.Data.Adaptive +open IcedTasks +open Navs +open Navs.Avalonia + +let view: SyncView = + fun ctx _ -> + let initialState = defaultValueArg (ctx.getParam "count") 0 + let value, setValue = AVal.useState initialState + + let increment () = setValue(fun v -> v + 1) + let decrement () = setValue(fun v -> v - 1) + let reset () = setValue(fun _ -> 0) + + let text = value |> AVal.map(fun v -> $"Count: {v}") + + StackPanel() + .Spacing(8) + .Children( + Button().Content("Increment").OnClickHandler(fun _ _ -> increment()), + Button().Content("Decrement").OnClickHandler(fun _ _ -> decrement()), + Button().Content("Reset").OnClickHandler(fun _ _ -> reset()), + TextBlock().Text(text |> AVal.toBinding) + ) + :> Control diff --git a/samples/NxuiDeclarative/Pages/Guid.fs b/samples/NxuiDeclarative/Pages/Guid.fs new file mode 100644 index 0000000..a7dc557 --- /dev/null +++ b/samples/NxuiDeclarative/Pages/Guid.fs @@ -0,0 +1,24 @@ +module Pages.Guid + +open Avalonia.Controls +open Avalonia.Layout +open Avalonia.Media +open Navs +open Navs.Avalonia +open NXUI.Extensions +open System + +let view: SyncView = + fun context _ -> + let content = + match context.getParam "id" with + | ValueSome guid -> TextBlock().Text($"Guid: {guid}") + + | ValueNone -> TextBlock().Text($"No Guid provided") + + content + .FontSize(24.) + .HorizontalAlignmentCenter() + .VerticalAlignmentCenter() + .Margin(16.) + :> Control diff --git a/samples/NxuiDeclarative/Program.fs b/samples/NxuiDeclarative/Program.fs new file mode 100644 index 0000000..8054fd1 --- /dev/null +++ b/samples/NxuiDeclarative/Program.fs @@ -0,0 +1,84 @@ +open System + +open Avalonia +open Avalonia.Controls +open Avalonia.Data +open NXUI.Desktop +open NXUI.Extensions + +open IcedTasks +open IcedTasks.Polyfill.Async.PolyfillBuilders + +open FSharp.Data.Adaptive +open Navs +open Navs.Avalonia +open Microsoft.Extensions.Logging +open Pages + +let lf = + LoggerFactory.Create(fun builder -> + builder.AddConsole().SetMinimumLevel LogLevel.Trace |> ignore + ) + +let logger = lf.CreateLogger "Navs.Avalonia.Program" + +let navigate url (router: IRouter voption) _ _ = + async { + match router with + | ValueNone -> + logger.LogInformation "Router is not initialized." + return () + | ValueSome router -> + let! result = router.Navigate url |> Async.AwaitTask + + match result with + | Ok _ -> () + | Error e -> logger.LogError("Navigation error: {Error}", e) + + return () + } + |> Async.StartImmediate + + +let AppRoutes (logger) = + Routes(logger = logger) + .Children( + Route("guid", "/?id", Guid.view), + Route("books", "/books?title", Books.view), + Route("counter", "/counter?count", Counter.view) + ) + +let app () = + let routes = AppRoutes logger + let router = routes.Router + + Window() + .Content( + DockPanel() + .LastChildFill(true) + .Children( + StackPanel() + .DockTop() + .OrientationHorizontal() + .Spacing(8) + .Children( + Button().Content("Books").OnClickHandler(navigate "/books" router), + Button() + .Content("Guid") + .OnClickHandler(navigate $"/?id={Guid.NewGuid()}" router), + Button() + .Content("Counter") + .OnClickHandler(navigate "/counter" router), + Button() + .Content("Counter with query") + .OnClickHandler(navigate "/counter?count=10" router), + Button() + .Content("Books with query") + .OnClickHandler(navigate "/books?title=The" router) + ), + routes + ) + ) + + +NXUI.Run(app, "Navs.Avalonia!", Environment.GetCommandLineArgs()) |> ignore diff --git a/samples/Routerish/Program.fs b/samples/Routerish/Program.fs index 9b73929..9e7375f 100644 --- a/samples/Routerish/Program.fs +++ b/samples/Routerish/Program.fs @@ -1,67 +1,113 @@ -open System - -open Avalonia -open Avalonia.Controls -open Avalonia.Data - -open NXUI.Desktop -open NXUI.FSharp.Extensions - -open Navs -open Navs.Avalonia - -let routes = [ - Route.define( - "guid", - "/:id", - fun context _ -> async { - return - match context.urlMatch.Params.TryGetValue "id" with - | true, id -> TextBlock().text($"%O{id}") - | false, _ -> TextBlock().text("Guid No GUID") - } - ) - Route.define("books", "/books", (fun _ _ -> TextBlock().text("Books"))) -] - -let getMainContent (router: IRouter) = - ContentControl() - .DockTop() - .content(router.Content |> AVal.toBinding, BindingMode.OneWay) - -let navigate url (router: IRouter) _ _ = - async { - let! result = router.Navigate(url) |> Async.AwaitTask - - match result with - | Ok _ -> () - | Error e -> printfn $"%A{e}" - } - |> Async.StartImmediate - -let app () = - - let router = - AvaloniaRouter(routes, splash = (fun _ -> TextBlock().text("Loading..."))) - - Window() - .content( - DockPanel() - .lastChildFill(true) - .children( - StackPanel() - .DockTop() - .OrientationHorizontal() - .spacing(8) - .children( - Button().content("Books").OnClickHandler(navigate "/books" router), - Button() - .content("Guid") - .OnClickHandler(navigate $"/{Guid.NewGuid()}" router) - ), - getMainContent(router) - ) - ) - - -NXUI.Run(app, "Navs.Avalonia!", Environment.GetCommandLineArgs()) |> ignore +open System + +open Avalonia +open Avalonia.Controls +open Avalonia.Data +open NXUI.Desktop +open NXUI.Extensions + +open FSharp.Data.Adaptive +open Navs +open Navs.Avalonia +open Microsoft.Extensions.Logging +open IcedTasks +open IcedTasks.Polyfill.Async.PolyfillBuilders + +let lf = + LoggerFactory.Create(fun builder -> + builder.AddConsole().SetMinimumLevel LogLevel.Trace |> ignore + ) + +let logger = lf.CreateLogger "Navs.Avalonia.Program" + +let routes = [ + Route.define( + "guid", + "/:id", + fun context _ -> async { + + return + match context.urlMatch.Params.TryGetValue "id" with + | true, id -> TextBlock().Text($"%O{id}") :> Control + | false, _ -> TextBlock().Text("Guid No GUID") + } + ) + Route.define( + "books", + "/books", + (fun _ _ -> TextBlock().Text("Books") :> Control) + ) + Route.define( + "counter", + "/counter?count", + (fun ctx _ -> + let initialState = defaultValueArg (ctx.getParam "count") 0 + let value, setValue = AVal.useState initialState + + let increment () = setValue(fun v -> v + 1) + let decrement () = setValue(fun v -> v - 1) + let reset () = setValue(fun _ -> 0) + + let text = value |> AVal.map(fun v -> $"Count: {v}") + + StackPanel() + .Spacing(8) + .Children( + Button().Content("Increment").OnClickHandler(fun _ _ -> increment()), + Button().Content("Decrement").OnClickHandler(fun _ _ -> decrement()), + Button().Content("Reset").OnClickHandler(fun _ _ -> reset()), + TextBlock().Text(text |> AVal.toBinding) + ) + :> Control + ) + ) + |> Route.cache NoCache +] + + +let navigate url (router: IRouter) _ _ = + async { + let! result = router.Navigate(url) |> Async.AwaitTask + + match result with + | Ok _ -> () + | Error e -> printfn $"%A{e}" + } + |> Async.StartImmediate + +let app () = + + let router = + AvaloniaRouter( + routes, + splash = (fun _ -> TextBlock().Text("Loading...")), + logger = logger + ) + + Window() + .Content( + DockPanel() + .LastChildFill(true) + .Children( + StackPanel() + .DockTop() + .OrientationHorizontal() + .Spacing(8) + .Children( + Button().Content("Books").OnClickHandler(navigate "/books" router), + Button() + .Content("Guid") + .OnClickHandler(navigate $"/{Guid.NewGuid()}" router), + Button() + .Content("Counter") + .OnClickHandler(navigate "/counter" router), + Button() + .Content("Counter with query") + .OnClickHandler(navigate "/counter?count=10" router) + ), + RouterOutlet().router(router).DockTop() + ) + ) + + +NXUI.Run(app, "Navs.Avalonia!", Environment.GetCommandLineArgs()) |> ignore diff --git a/samples/Routerish/Routerish.fsproj b/samples/Routerish/Routerish.fsproj index 7342349..19b1550 100644 --- a/samples/Routerish/Routerish.fsproj +++ b/samples/Routerish/Routerish.fsproj @@ -1,18 +1,18 @@ Exe - net9.0 + net10.0 + preview + false - - - - - - + + + + diff --git a/samples/Samples.sln b/samples/Samples.sln deleted file mode 100644 index 7e428e3..0000000 --- a/samples/Samples.sln +++ /dev/null @@ -1,37 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Routerish", "Routerish\Routerish.fsproj", "{C72ADAB3-DF7C-4038-A648-74633FB0261C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CSharpToo", "CSharpToo\CSharpToo.csproj", "{EEB5F596-B8B6-4A0C-B46C-F96B580E0917}" -EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FuncUI", "FuncUI\FuncUI.fsproj", "{2054F059-6FDC-480B-A086-83BD25FDC084}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C72ADAB3-DF7C-4038-A648-74633FB0261C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C72ADAB3-DF7C-4038-A648-74633FB0261C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C72ADAB3-DF7C-4038-A648-74633FB0261C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C72ADAB3-DF7C-4038-A648-74633FB0261C}.Release|Any CPU.Build.0 = Release|Any CPU - {EEB5F596-B8B6-4A0C-B46C-F96B580E0917}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EEB5F596-B8B6-4A0C-B46C-F96B580E0917}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EEB5F596-B8B6-4A0C-B46C-F96B580E0917}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EEB5F596-B8B6-4A0C-B46C-F96B580E0917}.Release|Any CPU.Build.0 = Release|Any CPU - {2054F059-6FDC-480B-A086-83BD25FDC084}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2054F059-6FDC-480B-A086-83BD25FDC084}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2054F059-6FDC-480B-A086-83BD25FDC084}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2054F059-6FDC-480B-A086-83BD25FDC084}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {632BFDE6-CD8C-4174-B3EC-4ABF88BB475E} - EndGlobalSection -EndGlobal diff --git a/samples/Samples.slnx b/samples/Samples.slnx new file mode 100644 index 0000000..ef8b175 --- /dev/null +++ b/samples/Samples.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/samples/TGUI/Extensions.fs b/samples/TGUI/Extensions.fs new file mode 100644 index 0000000..5ae636a --- /dev/null +++ b/samples/TGUI/Extensions.fs @@ -0,0 +1,218 @@ +namespace TGUI + +open Terminal.Gui.ViewBase +open System +open System.Runtime.CompilerServices +open FSharp.Data.Adaptive +open Terminal.Gui.Views + +[] +type ViewExtensions() = + + [] + static member inline Content(this: #View, [] views: View array) = + this.RemoveAll() |> ignore + + for view in views do + this.Add view |> ignore + + this + + [] + static member inline Content(this: #View, view: View aval) = + let disp = + view.AddWeakCallback(fun (v: View) -> + this.RemoveAll() |> ignore + this.Add(v) |> ignore + ) + + this.Disposing.Add(fun _ -> disp.Dispose()) + this + + [] + static member inline Title(this: #View, title: string) = + this.Title <- title + this + + [] + static member inline Title(this: #View, title: string aval) = + let dis = title.AddCallback(fun value -> this.Title <- value) + this.Disposing.Add(fun _ -> dis.Dispose()) + this + + [] + static member inline Title(this: #View, title: string cval) = + let dis = + this.TitleChanged + |> Observable.subscribe(fun s -> + transact(fun () -> title.Value <- this.Title) + ) + + let dis2 = title.AddCallback(fun value -> this.Title <- value) + + this.Disposing.Add(fun _ -> + dis.Dispose() + dis2.Dispose() + ) + + this + + [] + static member inline Enabled(this: #View, enabled: bool) = + this.Enabled <- enabled + this + + [] + static member inline Enabled(this: #View, enabled: bool aval) = + let dis = enabled.AddCallback(fun value -> this.Enabled <- value) + this.Disposing.Add(fun _ -> dis.Dispose()) + this + + [] + static member inline Enabled(this: #View, enabled: bool cval) = + let dis = + this.EnabledChanged + |> Observable.subscribe(fun s -> + transact(fun () -> enabled.Value <- this.Enabled) + ) + + let dis2 = enabled.AddCallback(fun value -> this.Enabled <- value) + + this.Disposing.Add(fun _ -> + dis.Dispose() + dis2.Dispose() + ) + + this + + [] + static member inline Visible(this: #View, visible: bool) = + this.Visible <- visible + this + + [] + static member inline Visible(this: #View, visible: bool aval) = + let dis = visible.AddCallback(fun value -> this.Visible <- value) + this.Disposing.Add(fun _ -> dis.Dispose()) + this + + [] + static member inline Visible(this: #View, visible: bool cval) = + let dis = + this.VisibleChanged + |> Observable.subscribe(fun s -> + transact(fun () -> visible.Value <- this.Visible) + ) + + let dis2 = visible.AddCallback(fun value -> this.Visible <- value) + + this.Disposing.Add(fun _ -> + dis.Dispose() + dis2.Dispose() + ) + + this + + [] + static member inline Text(this: #View, text: string) = + this.Text <- text + this + + [] + static member inline Text(this: #View, title: string aval) = + let dis = title.AddCallback(fun value -> this.Text <- value) + this.Disposing.Add(fun _ -> dis.Dispose()) + this + + [] + static member inline Text(this: #View, text: string cval) = + let dis = + this.TextChanged + |> Observable.subscribe(fun s -> + transact(fun () -> text.Value <- this.Text) + ) + + let dis2 = text.AddCallback(fun value -> this.Text <- value) + + this.Disposing.Add(fun _ -> + dis.Dispose() + dis2.Dispose() + ) + + this + + [] + static member inline X(this: #View, pos: Pos) = + this.X <- pos + this + + [] + static member inline X(this: #View, value) = + this.X <- Pos.op_Implicit value + this + + [] + static member inline Y(this: #View, pos: Pos) = + this.Y <- pos + this + + [] + static member inline Y(this: #View, value) = + this.Y <- Pos.op_Implicit value + this + + [] + static member inline Width(this: #View, dim: Dim | null) = + this.Width <- dim + this + + [] + static member inline Height(this: #View, dim: Dim | null) = + this.Height <- dim + this + +[] +type TextFieldExtensions() = + + [] + static member inline Secret(this: #TextField, isSecret: bool) = + this.Secret <- isSecret + this + +[] +type ButtonExtensions() = + + [] + static member inline IsDefault(this: #Button, isDefault: bool) = + this.IsDefault <- isDefault + this + + [] + static member inline OnAccept(this: #Button, [] action) = + this.Accepted.Add action + this + +[] +type Constructors = + static member inline Label(?text) = new Label(Text = defaultArg text "") + + static member inline TextField(?text) = + new TextField(Text = defaultArg text "") + + static member inline Button(?text) = new Button(Text = defaultArg text "") + + static member inline Window(?title) = new Window(Title = defaultArg title "") + + static member inline FrameView() = new FrameView() + + static member inline View([] views: View array) = + let v = new View() + v.Add(views) + v + + static member inline Pos(value: int) = Pos.op_Implicit value + + +module Task = + let inline FireAndForget (task: System.Threading.Tasks.Task) = + System.Threading.Tasks.Task.Run(fun () -> task) |> ignore diff --git a/samples/TGUI/Program.fs b/samples/TGUI/Program.fs new file mode 100644 index 0000000..216edc1 --- /dev/null +++ b/samples/TGUI/Program.fs @@ -0,0 +1,151 @@ +open Terminal.Gui +open Terminal.Gui.App +open Terminal.Gui.ViewBase +open Terminal.Gui.Views +open System +open TGUI + +open Navs +open UrlTemplates.RouteMatcher +open Navs.Terminal.Gui + + +let Login app (ctx: RouteContext) (navigable: INavigable<_>) = + let window = + Window + $"Example App ({App.Application.GetDefaultKey Input.Command.Quit} to quit)" + + let username = + UrlMatch.getParamFromQuery "username" ctx.urlMatch |> Option.ofValueOption + + + let usernameLabel = Label("Username:") + + let userNameText = + TextField(?text = username) + .X(Pos.Right(usernameLabel) + Pos(1)) + .Width(Dim.Fill()) + + let passwordLabel = + Label("Password:") + .X(Pos.Left(usernameLabel)) + .Y(Pos.Bottom(usernameLabel) + Pos(1)) + + let passwordText = + TextField() + .Secret(true) + .X(Pos.Left(userNameText)) + .Y(Pos.Top(passwordLabel)) + .Width(Dim.Fill()) + + let btnLogin = + Button("Login") + .X(Pos.Bottom(passwordLabel) + Pos(1)) + .Y(Pos.Center()) + .IsDefault(true) + .OnAccept(fun _ -> + if userNameText.Text = "admin" && passwordText.Text = "password" then + MessageBox.Query(app, "Logging In", "Login Successful", "Ok") + |> ignore + + app.RequestStop() + else + MessageBox.ErrorQuery( + app, + "Logging In", + "Incorrect username or password", + "Ok" + ) + |> ignore + ) + + let backButton = + Button("Home") + .X(Pos.Bottom(btnLogin) + Pos(1)) + .Y(Pos.Center()) + .OnAccept(fun _ -> navigable.NavigateByName("home") |> Task.FireAndForget) + + window.Content( + usernameLabel, + userNameText, + passwordLabel, + passwordText, + btnLogin, + backButton + ) + +let Home _ (navigable: INavigable) = + let label = Label("Welcome to the Home Page") + + let homeBtn = + Button("About") + .Y(Pos.Bottom(label)) + .OnAccept(fun _ -> + navigable.NavigateByName("about") + |> Async.AwaitTask + |> Async.Ignore + |> Async.StartImmediate + ) + + let login = + Button("Login") + .Y(Pos.Bottom(homeBtn)) + .OnAccept(fun _ -> + navigable.NavigateByName("login") |> Task.FireAndForget + ) + + Window("Home").Content(label, homeBtn, login) + +let About _ (navigable: INavigable) = + let label = Label("Welcome to the About Page") + + let homeBtn = + Button("Home") + .Y(Pos.Bottom(label)) + .OnAccept(fun _ -> navigable.NavigateByName("home") |> Task.FireAndForget + + ) + + let login = + Button("Login") + .Y(Pos.Bottom(homeBtn)) + .OnAccept(fun _ -> + navigable.NavigateByName("login") |> Task.FireAndForget + ) + + Window("About").Content(label, homeBtn, login) + + + +[] +let main argv = + // dotnet run --project samples/TGUI -- tgui:///login?username=admin + let app = Application.Create().Init() + + let routes = [ + Route.define("home", "/", Home) + Route.define("about", "/about", About) + Route.define("login", "/login?username", Login app) + ] + + let router: IRouter = TerminalGuiRouter routes + + let deepLink = + argv + |> Array.tryPick(fun arg -> + try + let uri = Uri(arg) + if uri.Scheme = "tgui" then Some(uri) else None + with _ -> + None + ) + |> Option.map(fun url -> url.PathAndQuery + url.Fragment) + |> Option.defaultValue("/") + + router.Navigate deepLink |> Task.FireAndForget + + app.Run(new RouterOutlet(router)) |> ignore + // Before the application exits, reset Terminal.Gui for clean shutdown + app.RequestStop() + + 0 // return an integer exit code diff --git a/samples/TGUI/TGUI.fsproj b/samples/TGUI/TGUI.fsproj new file mode 100644 index 0000000..892e43b --- /dev/null +++ b/samples/TGUI/TGUI.fsproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + false + + + + + + + + + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fa80f03..cadbc12 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -9,15 +9,12 @@ true 3390;$(WarnOn) README.md + enable + preview + $(MSBuildThisFileDirectory)../CHANGELOG.md - - - all - runtime; build; native; contentfiles; analyzers - - diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props new file mode 100644 index 0000000..351d8f8 --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,26 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Navs.Avalonia/Library.fs b/src/Navs.Avalonia/Library.fs index 701cf1a..1266009 100644 --- a/src/Navs.Avalonia/Library.fs +++ b/src/Navs.Avalonia/Library.fs @@ -1,397 +1,488 @@ -#nowarn "57" - -namespace Navs.Avalonia - -open System -open System.Runtime.InteropServices -open System.Runtime.CompilerServices -open System.Threading -open System.Threading.Tasks - -open Avalonia.Controls - -open FSharp.Data.Adaptive - -open Navs -open Navs.Router -open Avalonia -open Avalonia.Animation -open Avalonia.Data - -// enable extensions for VB.NE -[] -do () - -[] -module AVal = - - let inline getValue (adaptiveValue: aval<_>) = AVal.force adaptiveValue - - let inline setValue (adaptiveValue: cval<_>) value = - transact(fun () -> adaptiveValue.Value <- value) - - let inline mapSet (adaptiveValue: cval<_>) setValue = - transact(fun () -> adaptiveValue.Value <- setValue(adaptiveValue.Value)) - - let useState<'Value> (initialValue: 'Value) = - let v = cval initialValue - - let update mapValue = - transact(fun () -> v.Value <- mapValue(v.Value)) - - v :> aval<_>, update - - [] - let toObservable (value: aval<_>) = - { new IObservable<_> with - member _.Subscribe(observer) = value.AddCallback(observer.OnNext) - } - - [] - let toBinding<'Value> (value: aval<'Value>) = - (value |> toObservable).ToBinding() - - module Interop = - - let UseState<'Value> (initialValue: 'Value) = - let value = cval initialValue - - let action = - Action>(fun v -> - transact(fun () -> value.Value <- v.Invoke(value.Value)) - ) - - struct (value :> aval<_>, action) - -[] -module CVal = - - [] - let toBinding<'Value> (value: cval<'Value>) = - { new IBinding with - member _.Initiate - ( - target: Avalonia.AvaloniaObject, - targetProperty: Avalonia.AvaloniaProperty, - anchor: obj, - enableDataValidation: bool - ) : InstancedBinding = - - InstancedBinding.TwoWay( - { new IObservable with - member _.Subscribe(observer) = - value.AddCallback(observer.OnNext) - }, - { new IObserver with - member _.OnNext(newValue) = - match newValue with - | :? 'Value as newValue -> - transact(fun _ -> value.Value <- newValue) - | _ -> () - - member _.OnError(_) = () - member _.OnCompleted() = () - }, - BindingPriority.LocalValue - ) - } - - -[] -type AValExtensions = - - [] - static member inline getValue(adaptiveValue: aval<_>) = - AVal.force adaptiveValue - - [] - static member inline setValue(adaptiveValue: cval<_>, value) = - transact(fun () -> adaptiveValue.Value <- value) - - [] - static member inline setValue(adaptiveValue: cval<_>, setValue: Func<_, _>) = - transact(fun () -> - adaptiveValue.Value <- setValue.Invoke(adaptiveValue.Value) - ) - - [] - static member inline toBinding(value: aval<_>) = AVal.toBinding value - - [] - static member inline toBinding(value: cval<_>) = CVal.toBinding value - - [] - static member inline toObservable(value: aval<_>) = AVal.toObservable value - -type AvaloniaRouter(routes, [] ?splash: Func) = - let router = - let splash = splash |> Option.map(fun f -> fun () -> f.Invoke()) - Router.get(routes, ?splash = splash) - - - interface IRouter with - - member _.State = router.State - - member _.StateSnapshot = router.StateSnapshot - - member _.Route = router.Route - - member _.RouteSnapshot = router.RouteSnapshot - - member _.Content = router.Content - - member _.ContentSnapshot = router.ContentSnapshot - - - member _.Navigate(a, [] ?b) = - router.Navigate(a, ?cancellationToken = b) - - member _.NavigateByName(a, [] ?b, [] ?c) = - router.NavigateByName(a, ?routeParams = b, ?cancellationToken = c) - -[] -type Route = - - static member define - (name, path, handler: RouteContext -> INavigable -> Async<#Control>) : RouteDefinition< - Control - > - = - Navs.Route.define( - name, - path, - fun c n -> async { - let! result = handler c n - return result :> Control - } - ) - - static member define - ( - name, - path, - handler: - RouteContext - -> INavigable - -> CancellationToken - -> Task<#Control> - ) : RouteDefinition = - Navs.Route.define( - name, - path, - fun c n t -> task { - let! value = handler c n t - return value :> Control - } - ) - - static member define - (name, path, handler: RouteContext -> INavigable -> #Control) : RouteDefinition< - Control - > - = - Navs.Route.define(name, path, (fun c n -> handler c n :> Control)) - -module Interop = - - type Route = - [] - static member inline define - (name, path, handler: Func, #Control>) = - Navs.Route.define( - name, - path, - (fun c n -> handler.Invoke(c, n) :> Control) - ) - - [] - static member inline define - ( - name, - path, - handler: - Func< - RouteContext, - INavigable, - CancellationToken, - Task<#Control> - > - ) = - Navs.Route.define( - name, - path, - (fun c n t -> task { - let! value = handler.Invoke(c, n, t) - return value :> Control - }) - ) - -type RouterOutlet() as this = - inherit UserControl() - - let router = ref Unchecked.defaultof> - let pageTransition = ref Unchecked.defaultof - let noContent = ref Unchecked.defaultof - - let setupContent () = - - match router.Value :> obj with - | null -> this.Content <- null - | _ -> - let noContent = - match noContent.Value with - | null -> TextBlock(Text = "No Content") :> Control - | value -> value - - let content = - router.Value.Content - |> AVal.map(ValueOption.defaultValue noContent) - |> AVal.toBinding - - let pageTransition = - match pageTransition.Value with - | null -> - CompositePageTransition( - PageTransitions = - ResizeArray( - [ - CrossFade(TimeSpan.FromMilliseconds(150)) :> IPageTransition - PageSlide( - TimeSpan.FromMilliseconds(300), - PageSlide.SlideAxis.Horizontal - ) - ] - ) - ) - :> IPageTransition - | value -> value - - let transitionContent = - let transitionContent = - TransitioningContentControl(PageTransition = pageTransition) - - let binding = - TransitioningContentControl.ContentProperty - .Bind() - .WithMode(mode = BindingMode.OneWay) - .WithPriority(priority = BindingPriority.LocalValue) - - transitionContent[binding] <- content - transitionContent - - this[RouterOutlet.ContentProperty] <- transitionContent - - member this.Router - with get (): IRouter = router.Value - and set (value: IRouter) = - this.SetAndRaise(RouterOutlet.RouterProperty, router, value) |> ignore - setupContent() - - member this.PageTransition - with get () = pageTransition.Value - and set (value: IPageTransition) = - this.SetAndRaise( - RouterOutlet.PageTransitionProperty, - pageTransition, - value - ) - |> ignore - - member this.NoContent - with get () = noContent.Value - and set (value: Control) = - this.SetAndRaise(RouterOutlet.NoContentProperty, noContent, value) - |> ignore - - static member RouterProperty = - AvaloniaProperty.RegisterDirect>( - "Router", - (fun o -> o.Router), - (fun o v -> o.Router <- v) - ) - - static member PageTransitionProperty = - AvaloniaProperty.RegisterDirect( - "PageTransition", - (fun o -> o.PageTransition), - (fun o v -> o.PageTransition <- v) - ) - - static member NoContentProperty = - AvaloniaProperty.RegisterDirect( - "NoContent", - (fun o -> o.NoContent), - (fun o v -> o.NoContent <- v) - ) - -[] -type RouterOutletExtensions = - - [] - static member RouterOutlet() = RouterOutlet() - - [] - static member inline router - (routerOutlet: RouterOutlet, router: IRouter) - = - routerOutlet[RouterOutlet.RouterProperty] <- router - routerOutlet - - [] - - static member inline pageTransition - (routerOutlet: RouterOutlet, pageTransition: IPageTransition) - = - routerOutlet[RouterOutlet.PageTransitionProperty] <- pageTransition - routerOutlet - - [] - static member inline pageTransition - ( - routerOutlet: RouterOutlet, - pageTransition: aval, - [] ?mode: BindingMode, - [] ?priority: BindingPriority - ) = - let mode = defaultArg mode BindingMode.OneWay - let priority = defaultArg priority BindingPriority.LocalValue - - let descriptor = - RouterOutlet.PageTransitionProperty - .Bind() - .WithMode(mode = mode) - .WithPriority(priority = priority) - - routerOutlet[descriptor] <- (AVal.toObservable pageTransition).ToBinding() - routerOutlet - - [] - static member inline noContent - (routerOutlet: RouterOutlet, noContent: Control) - = - routerOutlet[RouterOutlet.NoContentProperty] <- noContent - routerOutlet - - [] - static member inline noContent - ( - routerOutlet: RouterOutlet, - noContent: aval, - [] ?mode: BindingMode, - [] ?priority: BindingPriority - ) = - let mode = defaultArg mode BindingMode.OneWay - let priority = defaultArg priority BindingPriority.LocalValue - - let descriptor = - RouterOutlet.NoContentProperty - .Bind() - .WithMode(mode = mode) - .WithPriority(priority = priority) - - routerOutlet[descriptor] <- (AVal.toObservable noContent).ToBinding() - routerOutlet +#nowarn "57" + +namespace Navs.Avalonia + +open System +open System.Runtime.InteropServices +open System.Runtime.CompilerServices +open System.Threading +open System.Threading.Tasks + +open Avalonia.Controls + +open FSharp.Data.Adaptive + +open Navs +open Navs.Router +open Avalonia +open Avalonia.Animation +open Avalonia.Data +open Microsoft.Extensions.Logging + +// enable extensions for VB.NE +[] +do () + +[] +module AVal = + + let inline getValue (adaptiveValue: aval<_>) = AVal.force adaptiveValue + + let inline setValue (adaptiveValue: cval<_>) value = + transact(fun () -> adaptiveValue.Value <- value) + + let inline mapSet (adaptiveValue: cval<_>) setValue = + transact(fun () -> adaptiveValue.Value <- setValue(adaptiveValue.Value)) + + let useState<'Value> (initialValue: 'Value) = + let v = cval initialValue + + let update mapValue = + transact(fun () -> v.Value <- mapValue(v.Value)) + + v :> aval<_>, update + + [] + let toObservable (value: aval<_>) = + { new IObservable<_> with + member _.Subscribe(observer) = value.AddCallback(observer.OnNext) + } + + [] + let toBinding<'Value> (value: aval<'Value>) = + (value |> toObservable).ToBinding() + + module Interop = + + let UseState<'Value> (initialValue: 'Value) = + let value = cval initialValue + + let action = + Action>(fun v -> + transact(fun () -> value.Value <- v.Invoke(value.Value)) + ) + + struct (value :> aval<_>, action) + +[] +type AValExtensions = + + [] + static member inline getValue(adaptiveValue: aval<_>) = + AVal.force adaptiveValue + + [] + static member inline setValue(adaptiveValue: cval<_>, value) = + transact(fun () -> adaptiveValue.Value <- value) + + [] + static member inline setValue(adaptiveValue: cval<_>, setValue: Func<_, _>) = + transact(fun () -> + adaptiveValue.Value <- setValue.Invoke(adaptiveValue.Value) + ) + + [] + static member inline toBinding(value: aval<_>) = AVal.toBinding value + + [] + static member inline toObservable(value: aval<_>) = AVal.toObservable value + +type AvaloniaRouter + ( + routes: RouteDefinition seq, + [] ?splash: Func, + [] ?logger: ILogger + ) = + let router = + let splash = splash |> Option.map(fun f -> fun () -> f.Invoke()) + Router.build(routes, ?splash = splash, ?logger = logger) + + + interface IRouter with + + member _.State = router.State + + member _.StateSnapshot = router.StateSnapshot + + member _.Route = router.Route + + member _.RouteSnapshot = router.RouteSnapshot + + member _.Content = router.Content + + member _.ContentSnapshot = router.ContentSnapshot + + + member _.Navigate(a, [] ?b) = + router.Navigate(a, ?cancellationToken = b) + + member _.NavigateByName(a, [] ?b, [] ?c) = + router.NavigateByName(a, ?routeParams = b, ?cancellationToken = c) + + +[] +type Route(def: RouteDefinition) = + inherit UserControl() + + new + ( + name: string, + path: string, + handler: RouteContext -> INavigable -> Control + ) = + Route(Route.define(name, path, handler)) + + new + ( + name: string, + path: string, + handler: RouteContext -> INavigable -> Async + ) = + Route(Route.define(name, path, handler)) + + new + ( + name: string, + path: string, + handler: + RouteContext + -> INavigable + -> CancellationToken + -> Task + ) = + Route(Route.define(name, path, handler)) + + member _.Definition: RouteDefinition = def + + + static member define + (name, path, handler: RouteContext -> INavigable -> Async) + = + Navs.Route.define( + name, + path, + fun c n -> async { + let! result = handler c n + return result + } + ) + + static member define + ( + name, + path, + handler: + RouteContext + -> INavigable + -> CancellationToken + -> Task + ) = + Navs.Route.define( + name, + path, + fun c n t -> task { + let! value = handler c n t + return value + } + ) + + static member define + (name, path, handler: RouteContext -> INavigable -> Control) + = + Navs.Route.define(name, path, (fun c n -> handler c n)) + + +[] +type Routes + ( + [] ?initialUri: string, + [] ?noView: Control, + [] ?logger: ILogger + ) as this = + inherit UserControl() + + let children: Route[] ref = ref Array.empty + + let mutable router: IRouter | null = + Unchecked.defaultof> + + let buildRouter (children: Route[]) : IRouter = + let definitions = children |> Array.map(fun c -> c.Definition) + + AvaloniaRouter(definitions, ?logger = logger) :> IRouter + + let content = + TransitioningContentControl( + PageTransition = + CompositePageTransition( + PageTransitions = + ResizeArray [ + CrossFade(TimeSpan.FromMilliseconds 150.) :> IPageTransition + PageSlide( + TimeSpan.FromMilliseconds 300., + PageSlide.SlideAxis.Horizontal + ) + ] + ) + ) + + let setupContent (router: IRouter) = + let binding = + TransitioningContentControl.ContentProperty + .Bind() + .WithMode(mode = BindingMode.OneWay) + + content[binding] <- + router.Content + |> AVal.map(fun content -> + let noView = + defaultArg noView (UserControl(Content = "No Content Provided.")) + + content |> ValueOption.defaultValue noView + ) + |> AVal.toBinding + + let navigateToFirstRoute (router: IRouter) = + let uri = defaultArg initialUri "/" + + async { + let! result = router.Navigate uri |> Async.AwaitTask |> Async.Catch + + match result with + | Choice1Of2 _ -> return () + | Choice2Of2 e -> + logger + |> Option.iter(fun logger -> + logger.LogError( + e, + "Failed to navigate to the initial Uri: {Uri}", + uri + ) + ) + + return () + } + |> Async.StartImmediate + + do this[Routes.ContentProperty] <- content + + member this.Children + with get (): Route[] = children.Value + and set (value: Route[]) = + + this.SetAndRaise(Routes.ChildrenProperty, children, value) |> ignore + let routr = buildRouter value + router <- routr + setupContent routr + navigateToFirstRoute routr + + member _.Router = + match router with + | null -> ValueNone + | router -> ValueSome router + + + static member ChildrenProperty = + AvaloniaProperty.RegisterDirect( + "Children", + _.Children, + (fun o v -> o.Children <- v) + ) + + +type RoutesExtensions = + + [] + static member inline Children + (control: Routes, [] routes: Route[]) + = + control.Children <- routes + control + + +module Interop = + + type Route = + [] + static member inline define + (name, path, handler: Func, Control>) + = + Navs.Route.define(name, path, (fun c n -> handler.Invoke(c, n))) + + [] + static member inline define + ( + name, + path, + handler: + Func< + RouteContext, + INavigable, + CancellationToken, + Task + > + ) = + Navs.Route.define( + name, + path, + (fun c n t -> task { + let! value = handler.Invoke(c, n, t) + return value + }) + ) + +type RouterOutlet() as this = + inherit UserControl() + + let router: (IRouter<_> | null) ref = ref Unchecked.defaultof> + + let pageTransition: (IPageTransition | null) ref = + ref Unchecked.defaultof + + let noContent: (Control | null) ref = ref Unchecked.defaultof + + let setupContent () = + + match router.Value with + | null -> this.Content <- null + | router -> + let noContent = + match noContent.Value with + | null -> TextBlock(Text = "No Content") :> Control + | value -> value + + let content = + router.Content + |> AVal.map(ValueOption.defaultValue noContent) + |> AVal.toBinding + + let pageTransition = + match pageTransition.Value with + | null -> + CompositePageTransition( + PageTransitions = + ResizeArray [ + CrossFade(TimeSpan.FromMilliseconds 150.) :> IPageTransition + PageSlide( + TimeSpan.FromMilliseconds 300., + PageSlide.SlideAxis.Horizontal + ) + ] + ) + :> IPageTransition + | value -> value + + let transitionContent = + let transitionContent = + TransitioningContentControl(PageTransition = pageTransition) + + let binding = + TransitioningContentControl.ContentProperty + .Bind() + .WithMode(mode = BindingMode.OneWay) + + transitionContent[binding] <- content + transitionContent + + this[RouterOutlet.ContentProperty] <- transitionContent + + member this.Router + with get (): IRouter | null = router.Value + and set (value: IRouter | null) = + this.SetAndRaise(RouterOutlet.RouterProperty, router, value) |> ignore + setupContent() + + member this.PageTransition + with get () = pageTransition.Value + and set (value: IPageTransition | null) = + this.SetAndRaise( + RouterOutlet.PageTransitionProperty, + pageTransition, + value + ) + |> ignore + + member this.NoContent + with get () = noContent.Value + and set (value: Control | null) = + this.SetAndRaise(RouterOutlet.NoContentProperty, noContent, value) + |> ignore + + static member RouterProperty = + AvaloniaProperty.RegisterDirect | null>( + "Router", + _.Router, + (fun o v -> o.Router <- v) + ) + + static member PageTransitionProperty = + AvaloniaProperty.RegisterDirect( + "PageTransition", + _.PageTransition, + (fun o v -> o.PageTransition <- v) + ) + + static member NoContentProperty = + AvaloniaProperty.RegisterDirect( + "NoContent", + _.NoContent, + (fun o v -> o.NoContent <- v) + ) + +type RouterOutletExtensions = + + [] + static member RouterOutlet() = RouterOutlet() + + [] + static member inline router + (routerOutlet: RouterOutlet, router: IRouter) + = + routerOutlet[RouterOutlet.RouterProperty] <- router + routerOutlet + + [] + + static member inline pageTransition + (routerOutlet: RouterOutlet, pageTransition: IPageTransition) + = + routerOutlet[RouterOutlet.PageTransitionProperty] <- pageTransition + routerOutlet + + [] + static member inline pageTransition + ( + routerOutlet: RouterOutlet, + pageTransition: aval, + [] ?mode: BindingMode, + [] ?priority: BindingPriority + ) = + let mode = defaultArg mode BindingMode.OneWay + + let descriptor = + RouterOutlet.PageTransitionProperty.Bind().WithMode(mode = mode) + + routerOutlet[descriptor] <- (AVal.toObservable pageTransition).ToBinding() + routerOutlet + + [] + static member inline noContent + (routerOutlet: RouterOutlet, noContent: Control) + = + routerOutlet[RouterOutlet.NoContentProperty] <- noContent + routerOutlet + + [] + static member inline noContent + ( + routerOutlet: RouterOutlet, + noContent: aval, + [] ?mode: BindingMode, + [] ?priority: BindingPriority + ) = + let mode = defaultArg mode BindingMode.OneWay + + let descriptor = RouterOutlet.NoContentProperty.Bind().WithMode(mode = mode) + + routerOutlet[descriptor] <- (AVal.toObservable noContent).ToBinding() + routerOutlet diff --git a/src/Navs.Avalonia/Library.fsi b/src/Navs.Avalonia/Library.fsi index c673584..b29dfa9 100644 --- a/src/Navs.Avalonia/Library.fsi +++ b/src/Navs.Avalonia/Library.fsi @@ -1,233 +1,276 @@ -namespace Navs.Avalonia - -open System -open System.Runtime.InteropServices -open System.Runtime.CompilerServices -open System.Threading -open System.Threading.Tasks - -open Avalonia -open Avalonia.Controls -open Avalonia.Data -open Avalonia.Animation - -open FSharp.Data.Adaptive -open Navs - -[] -module AVal = - - /// - /// Get the value of an adaptive value by forcing it - /// - val inline getValue: adaptiveValue: aval<'Value> -> 'Value - - /// - /// sets up a transaction and sets the value of a changeable value - /// - val inline setValue: adaptiveValue: cval<'Value> -> value: 'Value -> unit - - /// - /// sets up a transaction and sets the value resulting of the provided function - /// - val inline mapSet: adaptiveValue: cval<'Value> -> setValue: ('Value -> 'Value) -> unit - - /// - /// Provide a friendly interface to handle local state via Adaptive data - /// - val useState: initialValue: 'Value -> aval<'Value> * (('Value -> 'Value) -> unit) - - /// - /// Converts an adaptive value into an observable - /// - [] - val toObservable<'Value> : value: aval<'Value> -> IObservable<'Value> - - /// - /// Convert Adaptive data into a binding that can be handled by avalonia - /// - [] - val toBinding<'Value> : value: aval<'Value> -> IBinding - - module Interop = - - /// - /// Provide a dotnet interop friendly interface to handle local state via Adaptive data - /// - val UseState<'Value> : initialValue: 'Value -> struct (aval<'Value> * Action>) - -[] -module CVal = - - /// - /// Provides a double way binding for changeable values - /// - /// - /// This binding is read-write and can be used to bind to properties that support two-way binding. - /// If you're looking to just use a readonly binding, use the `toBinding` method with the AVal module instead. - /// - [] - val toBinding<'Value> : cval<'Value> -> IBinding - -/// -/// dotnet interop friendly API for adaptive and changeable data -/// -[] -type AValExtensions = - - /// - /// Get the value of an adaptive value by forcing it - /// - [] - static member inline getValue: adaptiveValue: aval<'Value> -> 'Value - - /// - /// sets up a transaction and sets the value of a changeable value - /// - [] - static member inline setValue: adaptiveValue: cval<'Value> * value: 'Value -> unit - - [] - static member inline setValue: adaptiveValue: cval<'Value> * setValue: Func<'Value, 'Value> -> unit - - /// - /// Creates a one-way binding from an adaptive value which can be bound to Avalonia properties. - /// - [] - static member inline toBinding: value: aval<'Value> -> IBinding - - /// - /// Creates a two-way binding from a changeable value which can be bound to Avalonia properties. - /// - /// - /// This binding is read-write and can be used to bind to properties that support two-way binding. - /// If you're looking to just use a readonly binding, use the `toBinding` method with an aval instead. - /// - [] - static member inline toBinding: value: cval<'Value> -> IBinding - - -/// -/// A router that is specialized to work with Avalonia types. -/// This router will render any object that inherits from Avalonia's of Control. -/// -type AvaloniaRouter = - /// The routes that the router will use to match the URL and render the view - /// - /// The router initially doesn't have a view to render. You can provide this function - /// to supply a splash-like (like mobile devices initial screen) view to render while you trigger the first navigation. - /// - new: routes: RouteDefinition seq * [] ?splash: Func -> AvaloniaRouter - - interface IRouter - -[] -type Route = - - ///Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// The view to render when the route is activated - /// A route definition - static member define: - name: string * path: string * handler: (RouteContext -> INavigable -> Async<#Control>) -> - RouteDefinition - - /// Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// An task returning function to render when the route is activated. - /// A route definition - /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. - static member define: - name: string * path: string * handler: (RouteContext -> INavigable -> CancellationToken -> Task<#Control>) -> - RouteDefinition - - ///Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// The view to render when the route is activated - /// A route definition - static member define: - name: string * path: string * handler: (RouteContext -> INavigable -> #Control) -> RouteDefinition - -/// -/// A module that contains the interop functions to use the Route class from other languages. -/// It is mostly the same API but using Func instead of F# function types. -/// -module Interop = - - [] - type Route = - ///Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// The view to render when the route is activated - /// A route definition - [] - static member inline define: - name: string * path: string * handler: Func, #Control> -> - RouteDefinition - - /// Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// An task returning function to render when the route is activated. - /// A route definition - /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. - [] - static member inline define: - name: string * path: string * handler: Func, CancellationToken, Task<#Control>> -> - RouteDefinition - -[] -type RouterOutlet = - inherit UserControl - - static member RouterProperty: DirectProperty> - - static member PageTransitionProperty: DirectProperty - - static member NoContentProperty: DirectProperty - - new: unit -> RouterOutlet - - member Router: IRouter with get, set - - member PageTransition: IPageTransition with get, set - - member NoContent: Control with get, set - -[] -type RouterOutletExtensions = - - [] - static member RouterOutlet: unit -> RouterOutlet - - [] - static member inline router: routerOutlet: RouterOutlet * router: IRouter -> RouterOutlet - - [] - static member inline pageTransition: routerOutlet: RouterOutlet * pageTransition: IPageTransition -> RouterOutlet - - [] - static member inline pageTransition: - routerOutlet: RouterOutlet * - pageTransition: aval * - [] ?mode: BindingMode * - [] ?priority: BindingPriority -> - RouterOutlet - - [] - static member inline noContent: routerOutlet: RouterOutlet * noContent: Control -> RouterOutlet - - [] - static member inline noContent: - routerOutlet: RouterOutlet * - noContent: aval * - [] ?mode: BindingMode * - [] ?priority: BindingPriority -> - RouterOutlet +namespace Navs.Avalonia + +open System +open System.Runtime.InteropServices +open System.Runtime.CompilerServices +open System.Threading +open System.Threading.Tasks +open Microsoft.Extensions.Logging +open Avalonia +open Avalonia.Controls +open Avalonia.Data +open Avalonia.Animation + +open FSharp.Data.Adaptive +open Navs + +[] +module AVal = + + /// + /// Get the value of an adaptive value by forcing it + /// + val inline getValue: adaptiveValue: aval<'Value> -> 'Value + + /// + /// sets up a transaction and sets the value of a changeable value + /// + val inline setValue: adaptiveValue: cval<'Value> -> value: 'Value -> unit + + /// + /// sets up a transaction and sets the value resulting of the provided function + /// + val inline mapSet: adaptiveValue: cval<'Value> -> setValue: ('Value -> 'Value) -> unit + + /// + /// Provide a friendly interface to handle local state via Adaptive data + /// + val useState: initialValue: 'Value -> aval<'Value> * (('Value -> 'Value) -> unit) + + /// + /// Converts an adaptive value into an observable + /// + [] + val toObservable<'Value> : value: aval<'Value> -> IObservable<'Value> + + /// + /// Convert Adaptive data into a binding that can be handled by avalonia + /// + [] + val toBinding<'Value> : value: aval<'Value> -> BindingBase + + module Interop = + + /// + /// Provide a dotnet interop friendly interface to handle local state via Adaptive data + /// + val UseState<'Value> : initialValue: 'Value -> struct (aval<'Value> * Action>) + +/// +/// dotnet interop friendly API for adaptive and changeable data +/// +[] +type AValExtensions = + + /// + /// Get the value of an adaptive value by forcing it + /// + [] + static member inline getValue: adaptiveValue: aval<'Value> -> 'Value + + /// + /// sets up a transaction and sets the value of a changeable value + /// + [] + static member inline setValue: adaptiveValue: cval<'Value> * value: 'Value -> unit + + [] + static member inline setValue: adaptiveValue: cval<'Value> * setValue: Func<'Value, 'Value> -> unit + + /// + /// Creates a one-way binding from an adaptive value which can be bound to Avalonia properties. + /// + [] + static member inline toBinding: value: aval<'Value> -> BindingBase + + +/// +/// A router that is specialized to work with Avalonia types. +/// This router will render any object that inherits from Avalonia's of Control. +/// +type AvaloniaRouter = + /// The routes that the router will use to match the URL and render the view + /// + /// The router initially doesn't have a view to render. You can provide this function + /// to supply a splash-like (like mobile devices initial screen) view to render while you trigger the first navigation. + /// + /// An optional logger to log the router's activity + new: + routes: RouteDefinition seq * [] ?splash: Func * [] ?logger: ILogger -> + AvaloniaRouter + + interface IRouter + +[] +type Route = + inherit UserControl + + /// + /// Gets the definition of the route. + /// + member Definition: RouteDefinition with get + + /// + /// Initializes a new instance of the Route class with a name and a handler. + /// + new: name: string * path: string * handler: (RouteContext -> INavigable -> Control) -> Route + + /// + /// Initializes a new instance of the Route class with a name and an asynchronous handler. + /// + new: name: string * path: string * handler: (RouteContext -> INavigable -> Async) -> Route + + /// + /// Initializes a new instance of the Route class with a name and an asynchronous handler. + /// + new: + name: string * path: string * handler: (RouteContext -> INavigable -> CancellationToken -> Task) -> + Route + + /// + /// Initializes a new instance of the Route class with a name. + /// + new: def: RouteDefinition -> Route + + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> Async) -> + RouteDefinition + + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// An task returning function to render when the route is activated. + /// A route definition + /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> CancellationToken -> Task) -> + RouteDefinition + + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> Control) -> RouteDefinition + +/// +/// Represents a collection of routes in the application. +/// +[] +type Routes = + inherit UserControl + + /// + /// Initializes a new instance of the Routes class with an initial URI and an optional logger. + /// /// The initial URI is used to set the initial route when the application starts. + /// + /// The initial URI to set the route to when the router starts. + /// An optional control to render when no route is matched. + /// An optional logger to log the router's activity. + /// + /// If you don't provide an initial URI, the navigation will default to "/" + /// if the route is not found the noView control will be rendered. + /// And you will have to programmatically navigate to a route using the `Navigate` method. + /// + new: [] ?initialUri: string * [] ?noView: Control * [] ?logger: ILogger -> Routes + + /// + /// Gets or sets the children routes. + /// + member Children: Route[] with get, set + member Router: IRouter voption with get + + /// + /// Gets the property for children routes. + /// + static member ChildrenProperty: DirectProperty + +[] +type RoutesExtensions = + + [] + static member inline Children: control: Routes * [] routes: Route[] -> Routes + +/// +/// A module that contains the interop functions to use the Route class from other languages. +/// It is mostly the same API but using Func instead of F# function types. +/// +module Interop = + + [] + type Route = + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + [] + static member inline define: + name: string * path: string * handler: Func, Control> -> + RouteDefinition + + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// An task returning function to render when the route is activated. + /// A route definition + /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. + [] + static member inline define: + name: string * path: string * handler: Func, CancellationToken, Task> -> + RouteDefinition + +[] +type RouterOutlet = + inherit UserControl + + static member RouterProperty: DirectProperty | null> + + static member PageTransitionProperty: DirectProperty + + static member NoContentProperty: DirectProperty + + new: unit -> RouterOutlet + + member Router: IRouter | null with get, set + + member PageTransition: IPageTransition | null with get, set + + member NoContent: Control | null with get, set + +[] +type RouterOutletExtensions = + + [] + static member RouterOutlet: unit -> RouterOutlet + + [] + static member inline router: routerOutlet: RouterOutlet * router: IRouter -> RouterOutlet + + [] + static member inline pageTransition: routerOutlet: RouterOutlet * pageTransition: IPageTransition -> RouterOutlet + + [] + static member inline pageTransition: + routerOutlet: RouterOutlet * + pageTransition: aval * + [] ?mode: BindingMode * + [] ?priority: BindingPriority -> + RouterOutlet + + [] + static member inline noContent: routerOutlet: RouterOutlet * noContent: Control -> RouterOutlet + + [] + static member inline noContent: + routerOutlet: RouterOutlet * + noContent: aval * + [] ?mode: BindingMode * + [] ?priority: BindingPriority -> + RouterOutlet diff --git a/src/Navs.Avalonia/Navs.Avalonia.fsproj b/src/Navs.Avalonia/Navs.Avalonia.fsproj index 46ab400..3c29361 100644 --- a/src/Navs.Avalonia/Navs.Avalonia.fsproj +++ b/src/Navs.Avalonia/Navs.Avalonia.fsproj @@ -1,8 +1,7 @@  - net8.0;net9.0 - true + net10.0;net8.0 @@ -10,7 +9,13 @@ - + + + + + + + diff --git a/src/Navs.FuncUI/Library.fs b/src/Navs.FuncUI/Library.fs index 04a5b3e..222849f 100644 --- a/src/Navs.FuncUI/Library.fs +++ b/src/Navs.FuncUI/Library.fs @@ -1,186 +1,215 @@ -namespace Navs.FuncUI - -open System -open System.Runtime.CompilerServices -open System.Runtime.InteropServices -open System.Threading -open System.Threading.Tasks - -open FSharp.Data.Adaptive - -open Avalonia.FuncUI -open Avalonia.FuncUI.DSL -open Avalonia.FuncUI.Types - -open Navs -open Navs.Router - -type FuncUIRouter - (routes: RouteDefinition seq, [] ?splash: Func) = - - let router = - let splash = splash |> Option.map(fun f -> fun () -> f.Invoke()) - Router.get(routes, ?splash = splash) - - - interface IRouter with - - member _.State = router.State - - member _.StateSnapshot = router.StateSnapshot - - member _.Route = router.Route - - member _.RouteSnapshot = router.RouteSnapshot - - member _.Content = router.Content - - member _.ContentSnapshot = router.ContentSnapshot - - member _.Navigate(a, [] ?b) = - router.Navigate(a, ?cancellationToken = b) - - member _.NavigateByName(a, [] ?b, [] ?c) = - router.NavigateByName(a, ?routeParams = b, ?cancellationToken = c) - - -type CValState<'Type>(initial: cval<'Type>) = - let instanceId = Guid.NewGuid() - - interface IWritable<'Type> with - member _.InstanceId = instanceId - member _.InstanceType = InstanceType.Source - - member _.ValueType = typeof<'Type> - - member _.Current = initial.Value - - member _.Subscribe(handler: 'Type -> unit) = initial.AddCallback(handler) - - member _.SubscribeAny(handler: obj -> unit) = initial.AddCallback(handler) - - member _.Set(value: 'Type) = - transact(fun _ -> initial.Value <- value) - - member _.Dispose() = () - - -[] -type IComponentContexExtensions = - - [] - static member inline useRouter - (context: IComponentContext, router: IRouter) - = - let view = context.useState(router.Content |> AVal.force) - - context.useEffect( - handler = (fun () -> router.Content.AddCallback(view.Set)), - triggers = [ EffectTrigger.AfterInit ] - ) - - view :> IReadable - - [] - static member inline useAVal(context: IComponentContext, aVal: aval<'a>) = - let view = context.useState(aVal |> AVal.force) - - context.useEffect( - handler = (fun () -> aVal.AddCallback(view.Set)), - triggers = [ EffectTrigger.AfterInit ] - ) - - view :> IReadable<'a> - - [] - static member useCval(context: IComponentContext, cVal: cval<'a>) = - context.usePassed(new CValState<'a>(cVal)) - - -type Route = - - static member define - (name, path, handler: RouteContext -> INavigable -> Async<#IView>) : RouteDefinition< - IView - > - = - Navs.Route.define( - name, - path, - fun c n -> async { - let! view = handler c n - return view :> IView - } - ) - - static member define - ( - name, - path, - handler: - RouteContext -> INavigable -> CancellationToken -> Task<#IView> - ) : RouteDefinition = - Navs.Route.define( - name, - path, - fun c n t -> task { - let! view = handler c n t - return view :> IView - } - ) - - static member define - (name, path, handler: RouteContext -> INavigable -> #IView) : RouteDefinition< - IView - > - = - Navs.Route.define(name, path, (fun c n -> handler c n :> IView)) - - -[] -module DSL = - open Avalonia.Animation - open Avalonia.Controls - - [] - type RouterOutlet = - - static member create - (router: IRouter, ?noContent: IView, ?transition: IPageTransition) = - Component.create( - "router-outlet", - fun ctx -> - let view = ctx.useRouter(router) - - let content = - view - |> State.readMap(fun v -> - v - |> ValueOption.defaultValue( - noContent |> Option.defaultValue(ContentControl.create []) - ) - ) - - let transition = - transition - |> Option.defaultWith(fun () -> - CompositePageTransition( - PageTransitions = - ResizeArray( - [ - CrossFade(TimeSpan.FromMilliseconds(150)) - :> IPageTransition - PageSlide( - TimeSpan.FromMilliseconds(300), - PageSlide.SlideAxis.Horizontal - ) - ] - ) - ) - ) - - TransitioningContentControl.create [ - TransitioningContentControl.content content.Current - TransitioningContentControl.pageTransition transition - ] - ) +namespace Navs.FuncUI + +open System +open System.Runtime.CompilerServices +open System.Runtime.InteropServices +open System.Threading +open System.Threading.Tasks + +open Microsoft.Extensions.Logging + +open FSharp.Data.Adaptive + +open Avalonia.FuncUI +open Avalonia.FuncUI.DSL +open Avalonia.FuncUI.Types + +open Navs +open Navs.Router + +type FuncUIRouter + ( + routes: RouteDefinition seq, + [] ?splash: Func, + [] ?logger: ILogger + ) = + + let router = + let splash = splash |> Option.map(fun f -> fun () -> f.Invoke()) + Router.build(routes, ?splash = splash, ?logger = logger) + + + interface IRouter with + + member _.State = router.State + + member _.StateSnapshot = router.StateSnapshot + + member _.Route = router.Route + + member _.RouteSnapshot = router.RouteSnapshot + + member _.Content = router.Content + + member _.ContentSnapshot = router.ContentSnapshot + + member _.Navigate(a, [] ?b) = + router.Navigate(a, ?cancellationToken = b) + + member _.NavigateByName(a, [] ?b, [] ?c) = + router.NavigateByName(a, ?routeParams = b, ?cancellationToken = c) + + +type CValState<'Type>(initial: cval<'Type>) = + let instanceId = Guid.NewGuid() + + interface IWritable<'Type> with + member _.InstanceId = instanceId + member _.InstanceType = InstanceType.Source + + member _.ValueType = typeof<'Type> + + member _.Current = initial.Value + + member _.Subscribe(handler: 'Type -> unit) = initial.AddCallback(handler) + + member _.SubscribeAny(handler: obj -> unit) = initial.AddCallback(handler) + + member _.Set(value: 'Type) = + transact(fun _ -> initial.Value <- value) + + member _.Dispose() = () + + +[] +type IComponentContexExtensions = + + [] + static member inline useRouter + (context: IComponentContext, router: IRouter) + = + let view = context.useState(router.Content |> AVal.force) + + context.useEffect( + handler = (fun () -> router.Content.AddCallback(view.Set)), + triggers = [ EffectTrigger.AfterInit ] + ) + + view :> IReadable + + [] + static member inline useAVal(context: IComponentContext, aVal: aval<'a>) = + let view = context.useState(aVal |> AVal.force) + + context.useEffect( + handler = (fun () -> aVal.AddCallback(view.Set)), + triggers = [ EffectTrigger.AfterInit ] + ) + + view :> IReadable<'a> + + [] + static member useCval(context: IComponentContext, cVal: cval<'a>) = + context.usePassed(new CValState<'a>(cVal)) + + +type Route = + + static member define + (name, path, handler: RouteContext -> INavigable -> Async<#IView>) + : RouteDefinition = + Navs.Route.define( + name, + path, + fun c n -> async { + let! view = handler c n + return view :> IView + } + ) + + static member define + ( + name, + path, + handler: + RouteContext -> INavigable -> CancellationToken -> Task<#IView> + ) : RouteDefinition = + Navs.Route.define( + name, + path, + fun c n t -> task { + let! view = handler c n t + return view :> IView + } + ) + + static member define + (name, path, handler: RouteContext -> INavigable -> #IView) + : RouteDefinition = + Navs.Route.define(name, path, (fun c n -> handler c n :> IView)) + +[] +module DSL = + open Avalonia.Animation + open Avalonia.Controls + + let private initialNavigation + (initialUri, router: IRouter, logger: ILogger option) + () + = + let uri = defaultArg initialUri "/" + + async { + let! result = router.Navigate(uri) |> Async.AwaitTask |> Async.Catch + + match result with + | Choice1Of2 _ -> () + | Choice2Of2 ex -> + match logger with + | Some l -> + l.LogError(ex, "Failed to navigate to initial URI: {Uri}", uri) + | None -> () + } + |> Async.StartImmediate + + [] + type RouterOutlet = + + static member create + ( + router: IRouter, + ?initialUri: string, + ?noContent: IView, + ?transition: IPageTransition, + ?logger: ILogger + ) = + Component.create( + "router-outlet", + fun ctx -> + let view = ctx.useRouter router + + ctx.useEffect( + handler = initialNavigation(initialUri, router, logger), + triggers = [ EffectTrigger.AfterInit ] + ) + + let content = + view + |> State.readMap(fun v -> + v + |> ValueOption.defaultValue( + noContent |> Option.defaultValue(ContentControl.create []) + ) + ) + + let transition = + transition + |> Option.defaultWith(fun () -> + CompositePageTransition( + PageTransitions = + ResizeArray [ + CrossFade(TimeSpan.FromMilliseconds 150.) + :> IPageTransition + PageSlide( + TimeSpan.FromMilliseconds 300., + PageSlide.SlideAxis.Horizontal + ) + ] + ) + ) + + TransitioningContentControl.create [ + TransitioningContentControl.content content.Current + TransitioningContentControl.pageTransition transition + ] + ) diff --git a/src/Navs.FuncUI/Library.fsi b/src/Navs.FuncUI/Library.fsi index 761ce58..80c146b 100644 --- a/src/Navs.FuncUI/Library.fsi +++ b/src/Navs.FuncUI/Library.fsi @@ -1,82 +1,88 @@ -namespace Navs.FuncUI - -open System -open System.Runtime.CompilerServices -open System.Runtime.InteropServices -open System.Threading -open System.Threading.Tasks - -open Avalonia.FuncUI -open Avalonia.FuncUI.Types - -open FSharp.Data.Adaptive - -open Navs -open Navs.Router - -/// -/// A router that is specialized to work with Avalonia.FuncUI types. -/// This router will render any object that implements the IView interface. -/// -type FuncUIRouter = - /// The routes that the router will use to match the URL and render the view - /// - /// The router initially doesn't have a view to render. You can provide this function - /// to supply a splash-like (like mobile devices initial screen) view to render while you trigger the first navigation. - /// - new: routes: RouteDefinition seq * [] ?splash: Func -> FuncUIRouter - - interface IRouter - -[] -type IComponentContexExtensions = - /// Subscribes to the router and returns an IReadable that emits the view that is being rendered. - [] - static member inline useRouter: context: IComponentContext * router: IRouter -> IReadable - - /// Subscribes to an adaptive value and returns an IReadable that will emit changes any time - /// The adaptive value gets an update. - [] - static member inline useAVal: context: IComponentContext * aVal: aval<'T> -> IReadable<'T> - - /// Takes a changeable value and returns an IWritable that will allow you to update and read from it. - [] - static member useCval: context: IComponentContext * cVal: cval<'T> -> IWritable<'T> - - -[] -type Route = - ///Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// The view to render when the route is activated - /// A route definition - static member define: - name: string * path: string * handler: (RouteContext -> INavigable -> Async<#IView>) -> - RouteDefinition - - /// Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// An task returning function to render when the route is activated. - /// A route definition - /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. - static member define: - name: string * path: string * handler: (RouteContext -> INavigable -> CancellationToken -> Task<#IView>) -> - RouteDefinition - - ///Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// The view to render when the route is activated - /// A route definition - static member define: - name: string * path: string * handler: (RouteContext -> INavigable -> #IView) -> RouteDefinition - -[] -module DSL = - open Avalonia.Animation - - [] - type RouterOutlet = - static member create: router: IRouter * ?noContent: IView * ?transition: IPageTransition -> IView +namespace Navs.FuncUI + +open System +open System.Runtime.CompilerServices +open System.Runtime.InteropServices +open System.Threading +open System.Threading.Tasks + +open Microsoft.Extensions.Logging +open Avalonia.FuncUI +open Avalonia.FuncUI.Types + +open FSharp.Data.Adaptive + +open Navs +open Navs.Router + +/// +/// A router that is specialized to work with Avalonia.FuncUI types. +/// This router will render any object that implements the IView interface. +/// +type FuncUIRouter = + /// The routes that the router will use to match the URL and render the view + /// + /// The router initially doesn't have a view to render. You can provide this function + /// to supply a splash-like (like mobile devices initial screen) view to render while you trigger the first navigation. + /// + /// An optional logger to log the router's activity + new: + routes: RouteDefinition seq * [] ?splash: Func * [] ?logger: ILogger -> + FuncUIRouter + + interface IRouter + +[] +type IComponentContexExtensions = + /// Subscribes to the router and returns an IReadable that emits the view that is being rendered. + [] + static member inline useRouter: context: IComponentContext * router: IRouter -> IReadable + + /// Subscribes to an adaptive value and returns an IReadable that will emit changes any time + /// The adaptive value gets an update. + [] + static member inline useAVal: context: IComponentContext * aVal: aval<'T> -> IReadable<'T> + + /// Takes a changeable value and returns an IWritable that will allow you to update and read from it. + [] + static member useCval: context: IComponentContext * cVal: cval<'T> -> IWritable<'T> + + +[] +type Route = + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> Async<#IView>) -> + RouteDefinition + + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// An task returning function to render when the route is activated. + /// A route definition + /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> CancellationToken -> Task<#IView>) -> + RouteDefinition + + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> #IView) -> RouteDefinition + +[] +module DSL = + open Avalonia.Animation + + [] + type RouterOutlet = + static member create: + router: IRouter * ?initialUri: string * ?noContent: IView * ?transition: IPageTransition * ?logger: ILogger -> + IView diff --git a/src/Navs.FuncUI/Navs.FuncUI.fsproj b/src/Navs.FuncUI/Navs.FuncUI.fsproj index 995b6c4..c4b2997 100644 --- a/src/Navs.FuncUI/Navs.FuncUI.fsproj +++ b/src/Navs.FuncUI/Navs.FuncUI.fsproj @@ -1,8 +1,7 @@  - net8.0;net9.0 - true + net10.0;net8.0 @@ -11,8 +10,16 @@ - - + + + + + + + + + + diff --git a/src/Navs.Terminal.Gui/Library.fs b/src/Navs.Terminal.Gui/Library.fs new file mode 100644 index 0000000..b8eeacb --- /dev/null +++ b/src/Navs.Terminal.Gui/Library.fs @@ -0,0 +1,209 @@ +namespace Navs.Terminal.Gui + +open System +open System.Runtime.CompilerServices +open System.Threading +open System.Threading.Tasks +open System.Runtime.InteropServices + +open Terminal.Gui +open Terminal.Gui.Views +open FSharp.Data.Adaptive + +open Navs +open Navs.Router +open Microsoft.Extensions.Logging + +// enable extensions for VB.NE +[] +do () + +[] +module AVal = + + let inline getValue (adaptiveValue: aval<_>) = AVal.force adaptiveValue + + let inline setValue (adaptiveValue: cval<_>) value = + transact(fun () -> adaptiveValue.Value <- value) + + let inline mapSet (adaptiveValue: cval<_>) setValue = + transact(fun () -> adaptiveValue.Value <- setValue(adaptiveValue.Value)) + + let useState<'Value> (initialValue: 'Value) = + let v = cval initialValue + + let update mapValue = + transact(fun () -> v.Value <- mapValue(v.Value)) + + v :> aval<_>, update + + module Interop = + + let UseState<'Value> (initialValue: 'Value) = + let value = cval initialValue + + let action = + Action>(fun v -> + transact(fun () -> value.Value <- v.Invoke(value.Value)) + ) + + struct (value :> aval<_>, action) + + +[] +type AValExtensions = + + [] + static member inline getValue(adaptiveValue: aval<_>) = + AVal.force adaptiveValue + + [] + static member inline setValue(adaptiveValue: cval<_>, value) = + transact(fun () -> adaptiveValue.Value <- value) + + [] + static member inline setValue(adaptiveValue: cval<_>, setValue: Func<_, _>) = + transact(fun () -> + adaptiveValue.Value <- setValue.Invoke(adaptiveValue.Value) + ) + +type TerminalGuiRouter + (routes, [] ?splash: Func, [] ?logger: ILogger) = + let router = + let splash = splash |> Option.map(fun f -> fun () -> f.Invoke()) + + Router.build(routes, ?splash = splash, ?logger = logger) + + interface IRouter with + + member _.State = router.State + + member _.StateSnapshot = router.StateSnapshot + + member _.Route = router.Route + + member _.RouteSnapshot = router.RouteSnapshot + + member _.Content = router.Content + + member _.ContentSnapshot = router.ContentSnapshot + + + member _.Navigate(a, [] ?b) = + router.Navigate(a, ?cancellationToken = b) + + member _.NavigateByName(a, [] ?b, [] ?c) = + router.NavigateByName(a, ?routeParams = b, ?cancellationToken = c) + +[] +type Route = + + static member define + (name, path, handler: RouteContext -> INavigable -> Async<#Window>) + : RouteDefinition = + Navs.Route.define( + name, + path, + fun c n -> async { + let! result = handler c n + return result :> Window + } + ) + + static member define + ( + name, + path, + handler: + RouteContext -> INavigable -> CancellationToken -> Task<#Window> + ) : RouteDefinition = + Navs.Route.define( + name, + path, + fun c n t -> task { + let! value = handler c n t + return value :> Window + } + ) + + static member define + (name, path, handler: RouteContext -> INavigable -> #Window) + : RouteDefinition = + Navs.Route.define(name, path, (fun c n -> handler c n :> Window)) + +module Interop = + + type Route = + [] + static member inline define + (name, path, handler: Func, #Window>) + = + Navs.Route.define(name, path, (fun c n -> handler.Invoke(c, n) :> Window)) + + [] + static member inline define + ( + name, + path, + handler: + Func< + RouteContext, + INavigable, + CancellationToken, + Task<#Window> + > + ) = + Navs.Route.define( + name, + path, + (fun c n t -> task { + let! value = handler.Invoke(c, n, t) + return value :> Window + }) + ) + +module private StartHelpers = + let initialNavigation + (initialUri, router: IRouter, logger: ILogger option) + = + let uri = defaultArg initialUri "/" + + async { + let! result = router.Navigate(uri) |> Async.AwaitTask |> Async.Catch + + match result with + | Choice1Of2 _ -> () + | Choice2Of2 ex -> + match logger with + | Some l -> + l.LogError(ex, "Failed to navigate to initial URI {Uri}", uri) + | None -> eprintfn "Failed to navigate to initial URI %s" uri + } + |> Async.StartImmediate + +type RouterOutlet + ( + router: IRouter, + [] ?initialUri: string, + [] ?logger: ILogger + ) as this = + inherit Runnable() + + let disposables = ResizeArray() + + do + router.Content.AddCallback(fun window -> + this.RemoveAll() |> ignore + + match window with + | ValueSome w -> this.Add(w :> ViewBase.View) |> ignore + | ValueNone -> () + ) + |> disposables.Add + + StartHelpers.initialNavigation(initialUri, router, logger) + + + override _.Dispose(disposing: bool) : unit = + disposables |> Seq.iter _.Dispose() + base.Dispose(disposing: bool) diff --git a/src/Navs.Terminal.Gui/Library.fsi b/src/Navs.Terminal.Gui/Library.fsi new file mode 100644 index 0000000..553f305 --- /dev/null +++ b/src/Navs.Terminal.Gui/Library.fsi @@ -0,0 +1,148 @@ +namespace Navs.Terminal.Gui + + +open System +open System.Runtime.CompilerServices +open System.Runtime.InteropServices +open System.Threading +open System.Threading.Tasks +open Microsoft.Extensions.Logging +open FSharp.Data.Adaptive +open Navs +open Terminal.Gui +open Terminal.Gui.Views + +[] +module AVal = + + /// + /// Get the value of an adaptive value by forcing it + /// + val inline getValue: adaptiveValue: aval<'Value> -> 'Value + + /// + /// sets up a transaction and sets the value of a changeable value + /// + val inline setValue: adaptiveValue: cval<'Value> -> value: 'Value -> unit + + /// + /// sets up a transaction and sets the value resulting of the provided function + /// + val inline mapSet: adaptiveValue: cval<'Value> -> setValue: ('Value -> 'Value) -> unit + + /// + /// Provide a friendly interface to handle local state via Adaptive data + /// + val useState: initialValue: 'Value -> aval<'Value> * (('Value -> 'Value) -> unit) + + module Interop = + + /// + /// Provide a dotnet interop friendly interface to handle local state via Adaptive data + /// + val UseState<'Value> : initialValue: 'Value -> struct (aval<'Value> * Action>) + +/// +/// dotnet interop friendly API for adaptive and changeable data +/// +[] +type AValExtensions = + + /// + /// Get the value of an adaptive value by forcing it + /// + [] + static member inline getValue: adaptiveValue: aval<'Value> -> 'Value + + /// + /// sets up a transaction and sets the value of a changeable value + /// + [] + static member inline setValue: adaptiveValue: cval<'Value> * value: 'Value -> unit + + [] + static member inline setValue: adaptiveValue: cval<'Value> * setValue: Func<'Value, 'Value> -> unit + + +/// +/// A router that is specialized to work with Terminal.Gui types. +/// This router will render any object that inherits from Terminal.Gui's of Window. +/// +type TerminalGuiRouter = + /// The routes that the router will use to match the URL and render the view + /// + /// The router initially doesn't have a view to render. You can provide this function + /// to supply a splash-like (like mobile devices initial screen) view to render while you trigger the first navigation. + /// + /// /// An optional logger to log the router's activity + new: + routes: RouteDefinition seq * [] ?splash: Func * [] ?logger: ILogger -> + TerminalGuiRouter + + interface IRouter + + + +[] +type Route = + + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> Async<#Window>) -> + RouteDefinition + + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// An task returning function to render when the route is activated. + /// A route definition + /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> CancellationToken -> Task<#Window>) -> + RouteDefinition + + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + static member define: + name: string * path: string * handler: (RouteContext -> INavigable -> #Window) -> RouteDefinition + +/// +/// A module that contains the interop functions to use the Route class from other languages. +/// It is mostly the same API but using Func instead of F# function types. +/// +module Interop = + + [] + type Route = + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + [] + static member inline define: + name: string * path: string * handler: Func, #Window> -> RouteDefinition + + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// An task returning function to render when the route is activated. + /// A route definition + /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. + [] + static member inline define: + name: string * path: string * handler: Func, CancellationToken, Task<#Window>> -> + RouteDefinition + +[] +type RouterOutlet = + inherit Runnable + + new: router: IRouter * [] ?initialUri: string * [] ?logger: ILogger -> RouterOutlet diff --git a/src/Navs.Terminal.Gui/Navs.Terminal.Gui.fsproj b/src/Navs.Terminal.Gui/Navs.Terminal.Gui.fsproj new file mode 100644 index 0000000..39a457a --- /dev/null +++ b/src/Navs.Terminal.Gui/Navs.Terminal.Gui.fsproj @@ -0,0 +1,26 @@ + + + + net10.0 + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Navs.Tests/DSL.fs b/src/Navs.Tests/DSL.fs similarity index 61% rename from tests/Navs.Tests/DSL.fs rename to src/Navs.Tests/DSL.fs index 4913914..f2e39a1 100644 --- a/tests/Navs.Tests/DSL.fs +++ b/src/Navs.Tests/DSL.fs @@ -6,12 +6,12 @@ open Navs open System.Threading.Tasks module Routes = + open System.Threading let defaultRoute = { name = "" pattern = "" - getContent = (fun _ _ _ -> Task.FromResult("")) - children = [] + getContent = GetView<_>(fun _ _ -> fun token -> ValueTask.FromResult("")) canActivate = [] canDeactivate = [] cacheStrategy = Cache @@ -28,23 +28,23 @@ module Routes = defaultRoute with name = "home" pattern = "/" - getContent = (fun _ _ _ -> Task.FromResult("Home")) + getContent = + GetView<_>(fun _ _ -> fun token -> ValueTask.FromResult("Home")) } Expect.equal route.name expected.name "Name should be equal" Expect.equal route.pattern expected.pattern "Pattern should be equal" + let token = CancellationToken.None let! actual = - route.getContent - Unchecked.defaultof<_> - Unchecked.defaultof<_> - Unchecked.defaultof<_> + route.getContent.Invoke + (Unchecked.defaultof<_>, Unchecked.defaultof<_>) + token let! expected = - expected.getContent - Unchecked.defaultof<_> - Unchecked.defaultof<_> - Unchecked.defaultof<_> + expected.getContent.Invoke + (Unchecked.defaultof<_>, Unchecked.defaultof<_>) + token Expect.equal actual expected "GetContent should be equal" } @@ -63,23 +63,23 @@ module Routes = defaultRoute with name = "home" pattern = "/" - getContent = fun _ _ _ -> Task.FromResult("Home") + getContent = + GetView<_>(fun _ _ -> fun token -> ValueTask.FromResult("Home")) } Expect.equal route.name expected.name "Name should be equal" Expect.equal route.pattern expected.pattern "Pattern should be equal" + let token = CancellationToken.None let! actual = - route.getContent - Unchecked.defaultof<_> - Unchecked.defaultof<_> - Unchecked.defaultof<_> + route.getContent.Invoke + (Unchecked.defaultof<_>, Unchecked.defaultof<_>) + token let! expected = - expected.getContent - Unchecked.defaultof<_> - Unchecked.defaultof<_> - Unchecked.defaultof<_> + expected.getContent.Invoke + (Unchecked.defaultof<_>, Unchecked.defaultof<_>) + token Expect.equal actual expected "GetContent should be equal" @@ -99,23 +99,23 @@ module Routes = defaultRoute with name = "home" pattern = "/" - getContent = fun _ _ _ -> Task.FromResult("Home") + getContent = + GetView<_>(fun _ _ -> fun token -> ValueTask.FromResult("Home")) } Expect.equal route.name expected.name "Name should be equal" Expect.equal route.pattern expected.pattern "Pattern should be equal" + let token = CancellationToken.None let! actual = - route.getContent - Unchecked.defaultof<_> - Unchecked.defaultof<_> - Unchecked.defaultof<_> + route.getContent.Invoke + (Unchecked.defaultof<_>, Unchecked.defaultof<_>) + token let! expected = - expected.getContent - Unchecked.defaultof<_> - Unchecked.defaultof<_> - Unchecked.defaultof<_> + expected.getContent.Invoke + (Unchecked.defaultof<_>, Unchecked.defaultof<_>) + token Expect.equal actual expected "GetContent should be equal" } diff --git a/tests/Navs.Tests/Main.fs b/src/Navs.Tests/Main.fs similarity index 100% rename from tests/Navs.Tests/Main.fs rename to src/Navs.Tests/Main.fs diff --git a/tests/Navs.Tests/Navs.Tests.fsproj b/src/Navs.Tests/Navs.Tests.fsproj similarity index 58% rename from tests/Navs.Tests/Navs.Tests.fsproj rename to src/Navs.Tests/Navs.Tests.fsproj index 6ea4c2b..d06ea88 100644 --- a/tests/Navs.Tests/Navs.Tests.fsproj +++ b/src/Navs.Tests/Navs.Tests.fsproj @@ -1,8 +1,9 @@ Exe - net8.0 + net10.0;net8.0 false + false @@ -10,9 +11,10 @@ - - - + + + + diff --git a/tests/Navs.Tests/Router.fs b/src/Navs.Tests/Router.fs similarity index 72% rename from tests/Navs.Tests/Router.fs rename to src/Navs.Tests/Router.fs index c9a0b1e..6a31dd0 100644 --- a/tests/Navs.Tests/Router.fs +++ b/src/Navs.Tests/Router.fs @@ -1,837 +1,781 @@ -module Navs.Tests.Router - -#nowarn "25" // infomplete pattern match - -open System -open System.Threading - -open Expecto -open FSharp.Data.Adaptive - -open Navs -open Navs.Router -open UrlTemplates.RouteMatcher - - -module NavigationState = - - let tests () = - testList "NavigationState tests" [ - test "State should be Idle by default" { - let router = Router.get([], (fun _ -> "Splash")) - - let state = router.State |> AVal.force - - Expect.equal state Idle "State should be Idle" - } - - testCaseTask "State should be Navigating when navigating" - <| fun () -> task { - let routes = [ - Route.define( - "home", - "/", - fun _ _ -> async { - do! Async.Sleep(TimeSpan.FromSeconds(10)) - return "Home" - } - ) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let state = router.State |> AVal.force - - Expect.equal state Idle "State should be Idle" - let cts = new CancellationTokenSource() - - router.Navigate("/", cts.Token) - |> Async.AwaitTask - |> Async.Ignore - |> Async.StartImmediate - - let state = router.State |> AVal.force - - Expect.equal state Navigating "State should be Navigating" - - cts.Cancel() // cancel navigation - - let state = router.State |> AVal.force - - Expect.equal state Idle "State should be Idle" - } - ] - -module RouteContext = - - - let tests () = - testList "RouteContext tests" [ - testCaseTask "RouteContext should contain the path" - <| fun () -> task { - let router = - Router.get( - [ - Route.define("home", "/", (fun _ _ -> "Home")) - Route.define("about", "/about", (fun _ _ -> "About")) - ], - (fun _ -> "Splash") - ) - - let context = router.Route |> AVal.force - - match context with - | ValueNone -> () - | _ -> failtest "There should not be a route initially" - - let! (Ok _) = router.Navigate("/") - - let (ValueSome context) = router.Route |> AVal.force - - Expect.equal context.path "/" "Path should be /" - - let! (Ok _) = router.Navigate("/about") - - let (ValueSome context) = router.Route |> AVal.force - - Expect.equal context.path "/about" "Path should be /about" - } - - testTask "Context should be none if navigation is cancelled or fails" { - let router = - Router.get( - [ - Route.define( - "home", - "/?complete", - fun ctx _ -> async { - match - UrlMatch.getFromParams "complete" ctx.urlMatch - with - | ValueSome true -> return "Home" - | _ -> - do! Async.Sleep(TimeSpan.FromSeconds(10)) - return "Home" - } - ) - ] - ) - - - let context = router.Route |> AVal.force - - match context with - | ValueNone -> () - | _ -> failtest "There should not be a route initially" - - let cts = new CancellationTokenSource() - - let! (Ok _) = router.Navigate("/?complete=true") - - let (ValueSome context) = router.Route |> AVal.force - - Expect.equal - context.path - "/?complete=true" - "Path should be /?complete=true" - - cts.CancelAfter(200) - let! _ = router.Navigate("/?complete=false", cts.Token) - - let context = router.Route |> AVal.force - - match context with - | ValueNone -> () - | ValueSome value -> failtestf "There should not be a route %A" value - - cts.Dispose() - } - - ] - -module Navigation = - type StatefulRoute = { - id: int - state: ref - updateState: unit -> unit - } - - let tests () = - testList "Navs Navigation Tests" [ - - test "Router is initialized without any views" { - let routes = [ Route.define("home", "/", (fun _ _ -> "Home")) ] - let router = Router.get(routes) - let view = router.Content |> AVal.force - - Expect.equal ValueNone view "View should be None" - } - - test "The Splash view should be provided before any navigation" { - let routes = [ Route.define("home", "/", (fun _ _ -> "Home")) ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal "Splash" view "View should be Splash" - } - - testCaseTask "The view should be updated when the route changes" - <| fun () -> task { - let routes = [ Route.define("home", "/", (fun _ _ -> "Home")) ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/") - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - Expect.equal view "Home" "View should be Home" - } - - testCaseTask "The view should be the fallback if not found" - <| fun () -> task { - let routes = [ Route.define("home", "/", (fun _ _ -> "Home")) ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! (Error(RouteNotFound value)) = router.Navigate("/not-found") - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal value "/not-found" "Value should be /not-found" - - Expect.equal view "Splash" "View should be Splash" - - } - - testCaseTask - "The view should change any time there's a successful navigation" - <| fun () -> task { - - let routes = [ - Route.define("home", "/", (fun _ _ -> "Home")) - Route.define("about", "/about", (fun _ _ -> "About")) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/about") - let (ValueSome firstNavigation) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/") - let (ValueSome secondNavigation) = router.Content |> AVal.force - - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal firstNavigation "About" "First navigation should be About" - - Expect.equal secondNavigation "Home" "Second navigation should be Home" - } - - testCaseTask "Children can be navigated" - <| fun () -> task { - let routes = [ - Route.define("users", "/users", (fun _ _ -> "Users")) - |> Route.child( - Route.define( - "user", - "/:id", - (fun ctx _ -> - let (ValueSome userId) = - UrlMatch.getFromParams "id" ctx.urlMatch - - $"User - %i{userId}" - ) - ) - ) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/users") - let (ValueSome users) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/users/1") - let (ValueSome view) = router.Content |> AVal.force - - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal users "Users" "Users should be the first navigation" - - Expect.equal view "User - 1" "View should be User - 1" - } - - testCaseTask "State is preserved if the route is the same" - <| fun () -> task { - - let routes = [ - Route.define( - "user", - "/?id", - fun ctx _ -> async { - let (ValueSome userId) = - UrlMatch.getFromParams "id" ctx.urlMatch - - let state = ref 10 - - return { - id = userId - state = state - updateState = (fun () -> state.Value <- state.Value + 1) - } - } - ) - ] - - let router = Router.get(routes) - - let! (Ok _) = router.Navigate("/?id=1") - - let (ValueSome { - id = id - state = state - updateState = updateState - }) = - router.Content |> AVal.force - - Expect.equal id 1 "Id should be 1" - Expect.equal state.Value 10 "State should be 10" - - updateState() - - let! (Ok _) = router.Navigate("/?id=1") - - let (ValueSome { id = id; state = state }) = - router.Content |> AVal.force - - Expect.equal id 1 "Id should be 1" - Expect.equal state.Value 11 "State should be 11" - } - - testCaseTask "State is not preserved if the query changes" - <| fun () -> task { - - let routes = [ - Route.define( - "user", - "/?id", - fun ctx _ -> async { - let (ValueSome userId) = - UrlMatch.getFromParams "id" ctx.urlMatch - - let state = ref 10 - - return { - id = userId - state = state - updateState = (fun () -> state.Value <- state.Value + 1) - } - } - ) - ] - - let router = Router.get(routes) - - let! (Ok _) = router.Navigate("/?id=1") - - let (ValueSome { - id = id - state = state - updateState = updateState - }) = - router.Content |> AVal.force - - Expect.equal id 1 "Id should be 1" - - updateState() - - Expect.equal state.Value 11 "State should be 11" - - let! (Ok _) = router.Navigate("/?id=2") - - let (ValueSome { id = id; state = state }) = - router.Content |> AVal.force - - Expect.equal id 2 "Id should be 2" - Expect.equal state.Value 10 "State should be 10" - } - - ] - -module Guards = - - let tests () = - testList "Guard Tests" [ - testCaseTask - $"Attempting to activate fails if canActivate returns {nameof Stop}" - <| fun () -> task { - let routes = [ - Route.define("home", "/", (fun _ _ -> "Home")) - |> Route.canActivate((fun _ _ -> async { return Stop })) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! Error(CantActivate definition) = router.Navigate("/") - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal definition "home" "Definition name should be home" - - } - - testCaseTask - $"Attempting to activate succeeds if canActivate returns {nameof Continue}" - <| fun () -> task { - let routes = [ - Route.define("home", "/", (fun _ _ -> "Home")) - |> Route.canActivate((fun _ _ -> async { return Continue })) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/") - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "Home" "View should be Home" - } - - testCaseTask - $"Attempting to navigate away fails if canDeactivate returns {nameof Stop}" - <| fun () -> task { - let routes = [ - Route.define("home", "/", (fun _ _ -> "Home")) - |> Route.canDeactivate((fun _ _ -> async { return Stop })) - Route.define("about", "/about", (fun _ _ -> "About")) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/") - - let! Error(CantDeactivate definition) = router.Navigate("/about") - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "Home" "View should be Home" - - Expect.equal definition "home" "Definition name should be home" - - } - - testCaseTask - $"Attempting to navigate away succeeds if canDeactivate returns {nameof Continue}" - <| fun () -> task { - let routes = [ - Route.define("home", "/", (fun _ _ -> "Home")) - |> Route.canDeactivate((fun _ _ -> async { return Continue })) - Route.define("about", "/about", (fun _ _ -> "About")) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/") - - let! (Ok _) = router.Navigate("/about") - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "About" "View should be About" - } - - testCaseTask "Parent Guard can prevent child activation" - <| fun () -> task { - let routes = [ - Route.define("users", "/users", (fun _ _ -> "Users")) - |> Route.canActivate((fun _ _ -> async { return Stop })) - |> Route.child( - Route.define( - "user", - "/:id", - (fun ctx _ -> - let (ValueSome userId) = - UrlMatch.getFromParams "id" ctx.urlMatch - - $"User - %i{userId}" - ) - ) - ) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! Error(CantActivate definition) = router.Navigate("/users/1") - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "Splash" "View should be Splash" - - Expect.equal definition "users" "Definition name should be users" - } - - testCaseTask "Parent Guard can allow child activation" - <| fun () -> task { - let routes = [ - Route.define("users", "/users", (fun _ _ -> "Users")) - |> Route.canActivate((fun _ _ -> async { return Continue })) - |> Route.child( - Route.define( - "user", - "/:id", - (fun ctx _ -> - let (ValueSome userId) = - UrlMatch.getFromParams "id" ctx.urlMatch - - $"User - %i{userId}" - ) - ) - ) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! (Ok _) = router.Navigate("/users/1") - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "User - 1" "View should be User - 1" - } - - testCaseTask "Child Guard can prevent deactivation" - <| fun () -> task { - let routes = [ - Route.define("users", "/users", (fun _ _ -> "Users")) - |> Route.child( - Route.define( - "user", - "/:id", - (fun ctx _ -> - let (ValueSome userId) = - UrlMatch.getFromParams "id" ctx.urlMatch - - $"User - %i{userId}" - ) - ) - |> Route.canActivate(fun _ _ -> async { return Stop }) - ) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! Error(CantActivate definition) = router.Navigate("/users/1") - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "Splash" "View should be Splash" - - Expect.equal definition "user" "Definition name should be user" - } - ] - - -module Cancellation = - - let tests () = - testList "Navigation cancellation tests" [ - - testCaseTask "Navigation can be cancelled" - <| fun () -> task { - let routes = [ - Route.define( - "home", - "/", - fun _ _ -> async { - do! Async.Sleep 5000 - return "Home" - } - ) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let cts = new CancellationTokenSource(10) - - let! Error(NavigationCancelled) = - router.Navigate("/", cancellationToken = cts.Token) - - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "Splash" "View should be Splash" - - cts.Dispose() - - } - - testCaseTask "Navigation can be cancelled while checking a guard" - <| fun () -> task { - - let routes = [ - Route.define( - "home", - "/", - fun _ _ -> async { - do! Async.Sleep 5000 - return "Home" - } - ) - |> Route.canActivate(fun _ _ -> async { - do! Async.Sleep 5000 - return Redirect "/login" - }) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let cts = new CancellationTokenSource(10) - - let! Error(NavigationCancelled) = - router.Navigate("/", cancellationToken = cts.Token) - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "Splash" "View should be Splash" - - cts.Dispose() - - } - - testCaseTask "Route Token can cance inner navigations" - <| fun () -> task { - let mutable count = 0 - - let routes = [ - Route.define( - "home", - "/", - fun _ (nav: INavigable<_>) -> async { - count <- count + 1 - let! token = Async.CancellationToken - - do! - nav.Navigate("/about", token) |> Async.AwaitTask |> Async.Ignore - - return "Home" - } - ) - Route.define( - "about", - "/about", - fun _ _ -> async { - do! Async.Sleep 5000 - count <- count + 1 - return "About" - } - ) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let cts = new CancellationTokenSource(200) - - let! Error(NavigationCancelled) = router.Navigate("/", cts.Token) - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "Splash" "View should be Splash" - - Expect.equal count 1 "Count should be 1" - - cts.Dispose() - - } - testCaseTask - "Navigations are successful and inner navigations can be cancelled" - <| fun () -> task { - let mutable count = 0 - - let routes = [ - Route.define( - "home", - "/", - fun _ (nav: INavigable<_>) -> async { - count <- count + 1 - let! token = Async.CancellationToken - - let navigate () = nav.Navigate("/about", token) |> ignore - - navigate() - - return $"Home 1" - } - ) - Route.define( - "about", - "/about", - fun _ _ -> async { - count <- count + 1 - do! Async.Sleep 5000 - count <- count + 1 - return "About" - } - ) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - let cts = new CancellationTokenSource() - - // First navigation count++ - // inner navigation starts count++ - // inner navigation cancels, count remains the same - match! router.Navigate("/", cts.Token) with - | Ok _ -> () - | Error e -> failtestf "Navigation should not fail %A" e - - match router.Content |> AVal.force with - | ValueSome view -> - Expect.equal view "Home 1" "Home 1 should be the view" - | _ -> failtestf "View should not be None" - - // allow the inner navigation to get cancelled - // by the cancellation token of the first navigation - cts.Cancel() - - match router.Content |> AVal.force with - | ValueSome view -> - Expect.equal view "Home 1" "Home 1 should be the view" - | _ -> failtestf "View should not be None" - - Expect.equal count 2 "Count should be 2" - - cts.Dispose() - - } - - testCaseTask - "Navigation completes before cancellation and it doesn't throw" - <| fun () -> task { - let routes = [ - Route.define( - "home", - "/", - fun _ _ -> async { - do! Async.Sleep 10 - return "Home" - } - ) - Route.define("about", "/about", (fun _ _ -> "About")) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let cts = new CancellationTokenSource() - cts.CancelAfter(1000) - let! Ok _ = router.Navigate("/", cts.Token) - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - Expect.equal view "Home" "Home should be the view" - - cts.Dispose() - } - - testCaseTask - "Navigation can be cancelled while checking deactivate guards" - <| fun () -> task { - let routes = [ - Route.define("home", "/", (fun _ _ -> "Home")) - |> Route.canDeactivate(fun _ _ -> async { - do! Async.Sleep 5000 - return Continue - }) - Route.define("about", "/about", (fun _ _ -> "About")) - ] - - let router = Router.get(routes, (fun _ -> "Splash")) - - let (ValueSome splash) = router.Content |> AVal.force - - let! Ok _ = router.Navigate("/") - - let cts = new CancellationTokenSource(100) - - let! Error(NavigationCancelled) = router.Navigate("/about", cts.Token) - - let (ValueSome view) = router.Content |> AVal.force - - Expect.equal splash "Splash" "Splash should be the initial view" - - Expect.equal view "Home" "View should be Home" - - cts.Dispose() - - } - ] - - -[] -let tests = - testList "Navs Router Tests" [ - NavigationState.tests() - RouteContext.tests() - Navigation.tests() - Guards.tests() - Cancellation.tests() - ] +module Navs.Tests.Router + +#nowarn "25" // incomplete pattern match + +open System +open System.Threading + +open Expecto +open FSharp.Data.Adaptive + +open FsToolkit.ErrorHandling + +open Navs +open Navs.Router +open UrlTemplates.RouteMatcher + +module NavigationState = + + let tests () = + testList "NavigationState tests" [ + test "State should be Idle by default" { + let router = Router.build([], (fun _ -> "Splash")) + + let state = router.State |> AVal.force + + Expect.equal state Idle "State should be Idle" + } + + testCaseTask "State should be Navigating when navigating" + <| fun () -> task { + let routes = [ + Route.define( + "home", + "/", + fun _ _ -> async { + do! Async.Sleep(TimeSpan.FromSeconds 10.) + return "Home" + } + ) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let state = router.State |> AVal.force + + Expect.equal state Idle "State should be Idle" + let cts = new CancellationTokenSource() + let mutable state = Unchecked.defaultof + + router.Navigate("/", cts.Token) + |> Async.AwaitTask + |> AsyncResult.teeError(fun v -> + if v.IsNavigationCancelled then + state <- router.State |> AVal.force + ) + |> Async.Ignore + |> Async.StartImmediate + + let state = router.State |> AVal.force + + Expect.equal state Navigating "State should be Navigating" + + cts.Cancel() // cancel navigation + + let state = router.State |> AVal.force + + Expect.equal state Idle "State should be Idle" + } + ] + +module RouteContext = + + + let tests () = + testList "RouteContext tests" [ + testCaseTask "RouteContext should contain the path" + <| fun () -> task { + let router = + Router.build( + [ + Route.define("home", "/", (fun _ _ -> "Home")) + Route.define("about", "/about", (fun _ _ -> "About")) + ], + (fun _ -> "Splash") + ) + + let context = router.Route |> AVal.force + + match context with + | ValueNone -> () + | _ -> failtest "There should not be a route initially" + + let! (Ok _) = router.Navigate("/") + + let (ValueSome context) = router.Route |> AVal.force + + Expect.equal context.path "/" "Path should be /" + + let! (Ok _) = router.Navigate("/about") + + let (ValueSome context) = router.Route |> AVal.force + + Expect.equal context.path "/about" "Path should be /about" + } + + testTask + "Context should equal to the last context if the next was cancelled" { + let router = + Router.build [ + Route.define( + "home", + "/?complete", + fun ctx _ -> async { + match UrlMatch.getFromParams "complete" ctx.urlMatch with + | ValueSome true -> return "Home" + | _ -> + do! Async.Sleep(TimeSpan.FromSeconds 10.) + return "Home" + } + ) + ] + + + let context = router.Route |> AVal.force + + match context with + | ValueNone -> () + | _ -> failtest "There should not be a route initially" + + + let! (Ok _) = router.Navigate("/?complete=true") + + let (ValueSome context) = router.Route |> AVal.force + + Expect.equal + context.path + "/?complete=true" + "Path should be /?complete=true" + + let cts = new CancellationTokenSource(100) + let! _ = router.Navigate("/?complete=false", cts.Token) + + let context = + router.Route + |> AVal.force + |> ValueOption.defaultWith(fun _ -> + failtest "Context should not be None" + ) + + let value = + context.urlMatch + |> UrlMatch.getFromParams "complete" + |> ValueOption.defaultWith(fun _ -> + failtest "Complete should be in the params" + ) + + Expect.isTrue value "Complete should be true" + + cts.Dispose() + } + + ] + +module Navigation = + type StatefulRoute = { + id: int + state: ref + updateState: unit -> unit + } + + let tests () = + testList "Navs Navigation Tests" [ + + test "Router is initialized without any views" { + let routes = [ Route.define("home", "/", (fun _ _ -> "Home")) ] + let router = Router.build(routes) + let view = router.Content |> AVal.force + + Expect.equal ValueNone view "View should be None" + } + + test "The Splash view should be provided before any navigation" { + let routes = [ Route.define("home", "/", (fun _ _ -> "Home")) ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal "Splash" view "View should be Splash" + } + + testCaseTask "The view should be updated when the route changes" + <| fun () -> task { + let routes = [ Route.define("home", "/", (fun _ _ -> "Home")) ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let! (Ok _) = router.Navigate("/") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + Expect.equal view "Home" "View should be Home" + } + + testCaseTask "The view should be the fallback if not found" + <| fun () -> task { + let routes = [ Route.define("home", "/", (fun _ _ -> "Home")) ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let! (Error(RouteNotFound value)) = router.Navigate("/not-found") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal value "/not-found" "Value should be /not-found" + + Expect.equal view "Splash" "View should be Splash" + + } + + testCaseTask + "The view should change any time there's a successful navigation" + <| fun () -> task { + + let routes = [ + Route.define("home", "/", (fun _ _ -> "Home")) + Route.define("about", "/about", (fun _ _ -> "About")) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let! (Ok _) = router.Navigate("/about") + let (ValueSome firstNavigation) = router.Content |> AVal.force + + let! (Ok _) = router.Navigate("/") + let (ValueSome secondNavigation) = router.Content |> AVal.force + + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal firstNavigation "About" "First navigation should be About" + + Expect.equal secondNavigation "Home" "Second navigation should be Home" + } + + testCaseTask "State is preserved if the route is the same" + <| fun () -> task { + + let routes = [ + Route.define( + "user", + "/?id", + fun ctx _ -> async { + let (ValueSome userId) = + UrlMatch.getFromParams "id" ctx.urlMatch + + let state = ref 10 + + return { + id = userId + state = state + updateState = (fun () -> state.Value <- state.Value + 1) + } + } + ) + ] + + let router = Router.build(routes) + + let! (Ok _) = router.Navigate("/?id=1") + + let (ValueSome { + id = id + state = state + updateState = updateState + }) = + router.Content |> AVal.force + + Expect.equal id 1 "Id should be 1" + Expect.equal state.Value 10 "State should be 10" + + updateState() + + let! (Ok _) = router.Navigate("/?id=1") + + let (ValueSome { id = id; state = state }) = + router.Content |> AVal.force + + Expect.equal id 1 "Id should be 1" + Expect.equal state.Value 11 "State should be 11" + } + + testCaseTask "State is not preserved if the query changes" + <| fun () -> task { + + let routes = [ + Route.define( + "user", + "/?id", + fun ctx _ -> async { + let (ValueSome userId) = + UrlMatch.getFromParams "id" ctx.urlMatch + + let state = ref 10 + + return { + id = userId + state = state + updateState = (fun () -> state.Value <- state.Value + 1) + } + } + ) + ] + + let router = Router.build(routes) + + let! (Ok _) = router.Navigate("/?id=1") + + let (ValueSome { + id = id + state = state + updateState = updateState + }) = + router.Content |> AVal.force + + Expect.equal id 1 "Id should be 1" + + updateState() + + Expect.equal state.Value 11 "State should be 11" + + let! (Ok _) = router.Navigate("/?id=2") + + let (ValueSome { id = id; state = state }) = + router.Content |> AVal.force + + Expect.equal id 2 "Id should be 2" + Expect.equal state.Value 10 "State should be 10" + } + + ] + +module Guards = + + let tests () = + testList "Guard Tests" [ + testCaseTask + $"Attempting to activate fails if canActivate returns {nameof Stop}" + <| fun () -> task { + let routes = [ + Route.define("home", "/", (fun _ _ -> "Home")) + |> Route.canActivateAsync(fun _ _ -> async { return Stop }) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let! Error(CantActivate definition) = router.Navigate("/") + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal definition "/" "Definition name should be home" + + } + + testCaseTask + $"Attempting to activate succeeds if canActivate returns {nameof Continue}" + <| fun () -> task { + let routes = [ + Route.define("home", "/", (fun _ _ -> "Home")) + |> Route.canActivateAsync(fun _ _ -> async { return Continue }) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let! (Ok _) = router.Navigate("/") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal view "Home" "View should be Home" + } + + testCaseTask + $"Attempting to navigate away fails if canDeactivate returns {nameof Stop}" + <| fun () -> task { + let routes = [ + Route.define("home", "/", (fun _ _ -> "Home")) + |> Route.canDeactivateAsync(fun _ _ -> async { return Stop }) + Route.define("about", "/about", (fun _ _ -> "About")) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let! (Ok _) = router.Navigate("/") + + let! Error(CantDeactivate definition) = router.Navigate("/about") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal view "Home" "View should be Home" + + Expect.equal definition "/" "Definition name should be home" + } + + testCaseTask + $"Attempting to navigate away succeeds if canDeactivate returns {nameof Continue}" + <| fun () -> task { + let routes = [ + Route.define("home", "/", (fun _ _ -> "Home")) + |> Route.canDeactivateAsync(fun _ _ -> async { return Continue }) + Route.define("about", "/about", (fun _ _ -> "About")) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let! (Ok _) = router.Navigate("/") + + let! (Ok _) = router.Navigate("/about") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal view "About" "View should be About" + } + ] + + +module Cancellation = + + let tests () = + testList "Navigation cancellation tests" [ + + testCaseTask "Navigation can be cancelled" + <| fun () -> task { + let routes = [ + Route.define( + "home", + "/", + fun _ _ -> async { + do! Async.Sleep 5000 + return "Home" + } + ) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let cts = new CancellationTokenSource(10) + + let! Error(NavigationCancelled) = + router.Navigate("/", cancellationToken = cts.Token) + + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal view "Splash" "View should be Splash" + + cts.Dispose() + + } + + testCaseTask "Navigation can be cancelled while checking a guard" + <| fun () -> task { + + let routes = [ + Route.define( + "home", + "/", + fun _ _ -> async { + do! Async.Sleep 5000 + return "Home" + } + ) + |> Route.canActivateAsync(fun _ _ -> async { + do! Async.Sleep 5000 + return Redirect "/login" + }) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let cts = new CancellationTokenSource(10) + + let! Error(NavigationCancelled) = + router.Navigate("/", cancellationToken = cts.Token) + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal view "Splash" "View should be Splash" + + cts.Dispose() + + } + + testCaseTask "Route Token can cancel inner navigations" + <| fun () -> task { + let mutable count = 0 + + let routes = [ + Route.define( + "home", + "/", + fun _ (nav: INavigable<_>) -> async { + count <- count + 1 + let! token = Async.CancellationToken + + do! + nav.Navigate("/about", token) |> Async.AwaitTask |> Async.Ignore + + return "Home" + } + ) + Route.define( + "about", + "/about", + fun _ _ -> async { + do! Async.Sleep 5000 + count <- count + 1 + return "About" + } + ) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let cts = new CancellationTokenSource(200) + + let! Error(NavigationCancelled) = router.Navigate("/", cts.Token) + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal view "Splash" "View should be Splash" + + Expect.equal count 1 "Count should be 1" + + cts.Dispose() + + } + testCaseTask + "Navigations are successful and inner navigations can be cancelled" + <| fun () -> task { + let mutable count = 0 + + let routes = [ + Route.define( + "home", + "/", + fun _ (nav: INavigable<_>) -> async { + count <- count + 1 + let! token = Async.CancellationToken + + let navigate () = nav.Navigate("/about", token) |> ignore + + navigate() + + return $"Home 1" + } + ) + Route.define( + "about", + "/about", + fun _ _ -> async { + count <- count + 1 + do! Async.Sleep 5000 + count <- count + 1 + return "About" + } + ) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + let cts = new CancellationTokenSource() + + // First navigation count++ + // inner navigation starts count++ + // inner navigation cancels, count remains the same + match! router.Navigate("/", cts.Token) with + | Ok _ -> () + | Error e -> failtestf $"Navigation should not fail %A{e}" + + match router.Content |> AVal.force with + | ValueSome view -> + Expect.equal view "Home 1" "Home 1 should be the view" + | _ -> failtestf "View should not be None" + + // allow the inner navigation to get cancelled + // by the cancellation token of the first navigation + cts.Cancel() + + match router.Content |> AVal.force with + | ValueSome view -> + Expect.equal view "Home 1" "Home 1 should be the view" + | _ -> failtestf "View should not be None" + + Expect.equal count 2 "Count should be 2" + + cts.Dispose() + + } + + testCaseTask + "Navigation completes before cancellation and it doesn't throw" + <| fun () -> task { + let routes = [ + Route.define( + "home", + "/", + fun _ _ -> async { + do! Async.Sleep 10 + return "Home" + } + ) + Route.define("about", "/about", (fun _ _ -> "About")) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let cts = new CancellationTokenSource() + cts.CancelAfter(1000) + let! Ok _ = router.Navigate("/", cts.Token) + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + Expect.equal view "Home" "Home should be the view" + + cts.Dispose() + } + + testCaseTask + "Navigation can be cancelled while checking deactivate guards" + <| fun () -> task { + let routes = [ + Route.define("home", "/", (fun _ _ -> "Home")) + |> Route.canDeactivateAsync(fun _ _ -> async { + do! Async.Sleep 5000 + return Continue + }) + Route.define("about", "/about", (fun _ _ -> "About")) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let (ValueSome splash) = router.Content |> AVal.force + + let! Ok _ = router.Navigate("/") + + let cts = new CancellationTokenSource(100) + + let! Error(NavigationCancelled) = router.Navigate("/about", cts.Token) + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal splash "Splash" "Splash should be the initial view" + + Expect.equal view "Home" "View should be Home" + + cts.Dispose() + + } + ] + + +module Redirect = + let tests () = + testList "Redirection Tests" [ + testCaseTask "Navigation can be redirected to another route" + <| fun () -> task { + let routes = [ + Route.define("home", "/", fun _ _ -> "Home") + |> Route.canActivate(fun _ _ -> Redirect "/about") + Route.define("about", "/about", (fun _ _ -> "About")) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let! Ok _ = router.Navigate("/") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal view "About" "View should be About" + } + + testCaseTask "Navigation can be redirected to another when deactivating" + <| fun () -> task { + let routes = [ + Route.define("home", "/", fun _ _ -> "Home") + |> Route.canDeactivate(fun _ _ -> Redirect "/about") + Route.define("about", "/about", (fun _ _ -> "About")) + Route.define("other", "/other", (fun _ _ -> "Other")) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let! Ok _ = router.Navigate("/") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal view "Home" "View should be Home" + + let! Ok _ = router.Navigate("/other") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal view "About" "View should be About" + } + + testCaseTask "Redirection Follows at least 3 navigations" + <| fun () -> task { + let routes = [ + Route.define("home", "/", fun _ _ -> "Home") + |> Route.canActivate(fun _ _ -> Redirect "/about") + Route.define("about", "/about", (fun _ _ -> "About")) + |> Route.canActivate(fun _ _ -> Redirect "/other") + Route.define("other", "/other", (fun _ _ -> "Other")) + |> Route.canActivate(fun _ _ -> Redirect "/other2") + Route.define("other2", "/other2", (fun _ _ -> "Other2")) + ] + + let router = Router.build(routes, (fun _ -> "Splash")) + + let! Ok _ = router.Navigate("/") + + let (ValueSome view) = router.Content |> AVal.force + + Expect.equal view "Other2" "View should be Other2" + + } + ] + + +[] +let tests = + testList "Navs Router Tests" [ + NavigationState.tests() + RouteContext.tests() + Navigation.tests() + Guards.tests() + Cancellation.tests() + Redirect.tests() + ] diff --git a/src/Navs/DSL.Interop.fs b/src/Navs/DSL.Interop.fs index 768ca3c..0a6882b 100644 --- a/src/Navs/DSL.Interop.fs +++ b/src/Navs/DSL.Interop.fs @@ -1,123 +1,182 @@ -namespace Navs.Interop - -open System -open System.Threading.Tasks -open System.Runtime.CompilerServices - -open Navs -open System.Threading - -// make sure extensions are visible in VB.NET -[] -do () - -type Route = - - static member inline Define<'View> - (name, path, getContent: Func, 'View>) = - { - name = name - pattern = path - getContent = fun ctx nav _ -> task { return getContent.Invoke(ctx, nav) } - children = [] - canActivate = [] - canDeactivate = [] - cacheStrategy = Cache - } - - static member inline Define<'View> - ( - name, - path, - getContent: - Func, CancellationToken, Task<'View>> - ) = - { - name = name - pattern = path - getContent = fun ctx nav token -> getContent.Invoke(ctx, nav, token) - children = [] - canActivate = [] - canDeactivate = [] - cacheStrategy = Cache - } - - -[] -module Guard = - let inline Continue () = Continue - let inline Stop () = Stop - - let inline Redirect url = Redirect url - - -[] -type RouteDefinitionExtensions = - - - [] - static member inline Child<'View> - (routeDef: RouteDefinition<'View>, child: RouteDefinition<'View>) = - Route.child child routeDef - - [] - static member inline Children<'View> - ( - routeDef: RouteDefinition<'View>, - [] children: RouteDefinition<'View> array - ) = - Route.children children routeDef - - [] - static member inline CanActivate<'View> - ( - routeDef: RouteDefinition<'View>, - [] guards: - Func< - RouteContext, - INavigable<'View>, - CancellationToken, - Task - > array - ) = - { - routeDef with - canActivate = [ - yield! routeDef.canActivate - for guard in guards do - FuncConvert.FromFunc(guard) - ] - } - - [] - static member inline CanDeactivate<'View> - ( - routeDef: RouteDefinition<'View>, - [] guards: - Func< - RouteContext, - INavigable<'View>, - CancellationToken, - Task - > array - ) = - { - routeDef with - canDeactivate = [ - yield! routeDef.canDeactivate - for guard in guards do - FuncConvert.FromFunc(guard) - ] - } - - [] - static member inline CacheOnVisit<'View>(routeDef: RouteDefinition<'View>) = { - routeDef with - cacheStrategy = CacheStrategy.Cache - } - - [] - static member inline NoCacheOnVisit<'View>(routeDef: RouteDefinition<'View>) = { - routeDef with - cacheStrategy = CacheStrategy.NoCache - } +namespace Navs.Interop + +open System +open System.Threading +open System.Threading.Tasks +open System.Runtime.CompilerServices +open IcedTasks +open Navs + +// make sure extensions are visible in VB.NET +[] +do () + + +type SyncView<'View> = Func, 'View> + +type TaskView<'View> = + Func, CancellationToken, Task<'View>> + +type SyncGuard = Func + +type AsyncGuard = + Func> + +type Route = + + static member inline Define<'View> + (name, path, getContent: Func, 'View>) + = + { + name = name + pattern = path + getContent = + GetView<'View>(fun ctx nav -> cancellableValueTask { + return getContent.Invoke(ctx, nav) + }) + canActivate = [] + canDeactivate = [] + cacheStrategy = Cache + } + + static member inline Define<'View> + ( + name, + path, + getContent: + Func, CancellationToken, Task<'View>> + ) = + { + name = name + pattern = path + getContent = + GetView<'View>(fun ctx nav -> cancellableValueTask { + let! token = CancellableValueTask.getCancellationToken() + return! getContent.Invoke(ctx, nav, token) + }) + canActivate = [] + canDeactivate = [] + cacheStrategy = Cache + } + + +[] +module Guard = + let inline Continue () = Continue + let inline Stop () = Stop + + let inline Redirect url = Redirect url + + +[] +type RouteDefinitionExtensions = + + [] + static member inline CanActivate<'View> + ( + routeDef: RouteDefinition<'View>, + [] guards: + Func array + ) = + { + routeDef with + canActivate = [ + yield! routeDef.canActivate + for guard in guards do + RouteGuard<'View>(fun ctx nextCtx -> cancellableValueTask { + let value = + ctx |> ValueOption.defaultValue(Unchecked.defaultof<_>) + + return guard.Invoke(value, nextCtx) + }) + ] + } + + [] + static member inline CanActivate<'View> + ( + routeDef: RouteDefinition<'View>, + [] guards: + Func< + RouteContext | null, + RouteContext, + CancellationToken, + Task + > array + ) = + { + routeDef with + canActivate = [ + yield! routeDef.canActivate + for guard in guards do + RouteGuard<'View>(fun ctx nextCtx -> cancellableValueTask { + let! token = CancellableValueTask.getCancellationToken() + + let value = + ctx |> ValueOption.defaultValue(Unchecked.defaultof<_>) + + return! guard.Invoke(value, nextCtx, token) + }) + ] + } + + [] + static member inline CanDeactivate<'View> + ( + routeDef: RouteDefinition<'View>, + [] guards: + Func array + ) = + { + routeDef with + canDeactivate = [ + yield! routeDef.canDeactivate + for guard in guards do + RouteGuard<'View>(fun ctx nextCtx -> cancellableValueTask { + let value = + ctx |> ValueOption.defaultValue(Unchecked.defaultof<_>) + + return guard.Invoke(value, nextCtx) + }) + ] + } + + [] + static member inline CanDeactivate<'View> + ( + routeDef: RouteDefinition<'View>, + [] guards: + Func< + RouteContext | null, + RouteContext, + CancellationToken, + Task + > array + ) = + { + routeDef with + canDeactivate = [ + yield! routeDef.canDeactivate + for guard in guards do + RouteGuard<'View>(fun ctx nextCtx -> cancellableValueTask { + let! token = CancellableValueTask.getCancellationToken() + + let value = + ctx |> ValueOption.defaultValue(Unchecked.defaultof<_>) + + return! guard.Invoke(value, nextCtx, token) + }) + ] + } + + [] + static member inline CacheOnVisit<'View>(routeDef: RouteDefinition<'View>) = { + routeDef with + cacheStrategy = CacheStrategy.Cache + } + + [] + static member inline NoCacheOnVisit<'View>(routeDef: RouteDefinition<'View>) = { + routeDef with + cacheStrategy = CacheStrategy.NoCache + } diff --git a/src/Navs/DSL.Interop.fsi b/src/Navs/DSL.Interop.fsi index 2eab8d1..62b79e4 100644 --- a/src/Navs/DSL.Interop.fsi +++ b/src/Navs/DSL.Interop.fsi @@ -1,103 +1,102 @@ -namespace Navs.Interop - -open System -open System.Runtime.CompilerServices -open System.Threading -open System.Threading.Tasks - -open Navs - -[] -type Route = - /// Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// The delegate that will be called to render the view when the route is activated - /// A route definition - /// - /// This function should ideally be used from non-F# languages as it provides a more standard Function signature. - /// - static member inline Define: - name: string * path: string * getContent: Func, 'View> -> RouteDefinition<'View> - - /// Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// The delegate that will be called to render the view when the route is activated - /// A route definition - /// - /// This function should ideally be used from F# as it provides a more idiomatic F# Function signature. - /// - static member inline Define: - name: string * path: string * getContent: Func, CancellationToken, Task<'View>> -> - RouteDefinition<'View> - -/// -/// A module that provides functions for route guard responses -/// -[] -module Guard = - - /// - /// A guard response that indicates that the navigation should continue - /// - val inline Continue: unit -> GuardResponse - - /// - /// A guard response that indicates that the navigation should stop and fail the navigation - /// - val inline Stop: unit -> GuardResponse - - /// - /// A guard response that indicates that the navigation should stop and redirect to the specified URL - /// - val inline Redirect: string -> GuardResponse - -/// -/// Extensions for a builder-like API for defining routes in the application -/// -[] -type RouteDefinitionExtensions = - - /// - /// Takes a route definition and adds it as a child of the parent route definition. - /// - [] - static member inline Child: routeDef: RouteDefinition<'View> * child: RouteDefinition<'View> -> RouteDefinition<'View> - - /// - /// Takes a sequence of route definitions and adds them as children of the parent route definition. - /// - [] - static member inline Children: - routeDef: RouteDefinition<'View> * [] children: RouteDefinition<'View> array -> RouteDefinition<'View> - - /// - /// Takes a sequence of route guards and adds them to the route definition as guards that will be executed when the route is activated. - /// - [] - static member inline CanActivate: - routeDef: RouteDefinition<'View> * - [] guards: Func, CancellationToken, Task> array -> - RouteDefinition<'View> - - /// - /// Takes a sequence of route guards and adds them to the route definition as guards that will be executed when the route is deactivated. - /// - [] - static member inline CanDeactivate: - routeDef: RouteDefinition<'View> * - [] guards: Func, CancellationToken, Task> array -> - RouteDefinition<'View> - - /// - /// Ensure that rendered view used for this route is picked up from the in-memory cache. - /// - [] - static member inline CacheOnVisit: routeDef: RouteDefinition<'View> -> RouteDefinition<'View> - - /// - /// Ensure that rendered view used for this route is always re-rendered when the route is activated. - /// - [] - static member inline NoCacheOnVisit: routeDef: RouteDefinition<'View> -> RouteDefinition<'View> +namespace Navs.Interop + +open System +open System.Runtime.CompilerServices +open System.Threading +open System.Threading.Tasks + +open Navs + +type SyncView<'View> = Func, 'View> +type TaskView<'View> = Func, CancellationToken, Task<'View>> +type SyncGuard = Func +type AsyncGuard = Func> + +[] +type Route = + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The delegate that will be called to render the view when the route is activated + /// A route definition + /// + /// This function should ideally be used from non-F# languages as it provides a more standard Function signature. + /// + static member inline Define: name: string * path: string * getContent: SyncView<'View> -> RouteDefinition<'View> + + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The delegate that will be called to render the view when the route is activated + /// A route definition + /// + /// This function should ideally be used from F# as it provides a more idiomatic F# Function signature. + /// + static member inline Define: name: string * path: string * getContent: TaskView<'View> -> RouteDefinition<'View> + +/// +/// A module that provides functions for route guard responses +/// +[] +module Guard = + + /// + /// A guard response that indicates that the navigation should continue + /// + val inline Continue: unit -> GuardResponse + + /// + /// A guard response that indicates that the navigation should stop and fail the navigation + /// + val inline Stop: unit -> GuardResponse + + /// + /// A guard response that indicates that the navigation should stop and redirect to the specified URL + /// + val inline Redirect: string -> GuardResponse + +/// +/// Extensions for a builder-like API for defining routes in the application +/// +[] +type RouteDefinitionExtensions = + + /// + /// Takes a sequence of route guards and adds them to the route definition as guards that will be executed when the route is activated. + /// + [] + static member inline CanActivate: + routeDef: RouteDefinition<'View> * [] guards: SyncGuard array -> RouteDefinition<'View> + + /// + /// Takes a sequence of route guards and adds them to the route definition as guards that will be executed when the route is activated. + /// + [] + static member inline CanActivate: + routeDef: RouteDefinition<'View> * [] guards: AsyncGuard array -> RouteDefinition<'View> + + /// + /// Takes a sequence of route guards and adds them to the route definition as guards that will be executed when the route is activated. + /// + [] + static member inline CanDeactivate: + routeDef: RouteDefinition<'View> * [] guards: SyncGuard array -> RouteDefinition<'View> + + /// + /// Takes a sequence of route guards and adds them to the route definition as guards that will be executed when the route is deactivated. + /// + [] + static member inline CanDeactivate: + routeDef: RouteDefinition<'View> * [] guards: AsyncGuard array -> RouteDefinition<'View> + + /// + /// Ensure that rendered view used for this route is picked up from the in-memory cache. + /// + [] + static member inline CacheOnVisit: routeDef: RouteDefinition<'View> -> RouteDefinition<'View> + + /// + /// Ensure that rendered view used for this route is always re-rendered when the route is activated. + /// + [] + static member inline NoCacheOnVisit: routeDef: RouteDefinition<'View> -> RouteDefinition<'View> diff --git a/src/Navs/DSL.fs b/src/Navs/DSL.fs index 5e6120d..8ff011e 100644 --- a/src/Navs/DSL.fs +++ b/src/Navs/DSL.fs @@ -1,150 +1,182 @@ -namespace Navs - -open System -open System.Threading.Tasks -open System.Threading - -type Route = - - static member inline define<'View> - ( - name, - path, - [] handler: RouteContext -> INavigable<'View> -> 'View - ) = - { - name = name - pattern = path - getContent = fun ctx nav _ -> task { return handler ctx nav } - children = [] - canActivate = [] - canDeactivate = [] - cacheStrategy = Cache - } - - static member inline define<'View> - ( - name, - path, - [] handler: - RouteContext -> INavigable<'View> -> Async<'View> - ) = - { - name = name - pattern = path - getContent = - fun ctx nav token -> - Async.StartImmediateAsTask(handler ctx nav, cancellationToken = token) - - children = [] - canActivate = [] - canDeactivate = [] - cacheStrategy = Cache - } - - static member inline define<'View> - ( - name, - path: string, - [] handler: - RouteContext -> INavigable<'View> -> CancellationToken -> Task<'View> - ) = - { - name = name - pattern = path - getContent = handler - children = [] - canActivate = [] - canDeactivate = [] - cacheStrategy = Cache - } - - static member inline child child definition : RouteDefinition<_> = { - definition with - children = - { - child with - pattern = - if child.pattern.StartsWith('/') then - child.pattern[1..] - else - child.pattern - } - :: definition.children - } - - static member inline children children definition : RouteDefinition<_> = { - definition with - children = [ - yield! children - for child in definition.children -> - { - child with - pattern = - if child.pattern.StartsWith('/') then - child.pattern[1..] - else - child.pattern - } - ] - } - - static member inline cache strategy definition : RouteDefinition<_> = { - definition with - cacheStrategy = strategy - } - -module Route = - let inline canActivateTask - ([] guard: - RouteContext -> INavigable<_> -> CancellationToken -> Task) - definition - : RouteDefinition<_> = - { - definition with - canActivate = guard :: definition.canActivate - } - - let inline canActivate - ([] guard: - RouteContext -> INavigable<_> -> Async) - definition - : RouteDefinition<_> = - { - definition with - canActivate = - (fun ctx nav token -> - Async.StartImmediateAsTask( - guard ctx nav, - cancellationToken = token - ) - ) - :: definition.canActivate - } - - let inline canDeactivateTask - ([] guard: - RouteContext -> INavigable<_> -> CancellationToken -> Task) - definition - : RouteDefinition<_> = - { - definition with - canDeactivate = guard :: definition.canDeactivate - } - - let inline canDeactivate - ([] guard: - RouteContext -> INavigable<_> -> Async) - definition - : RouteDefinition<_> = - { - definition with - canDeactivate = - (fun ctx nav token -> - Async.StartImmediateAsTask( - guard ctx nav, - cancellationToken = token - ) - ) - :: definition.canDeactivate - } +namespace Navs + +open System +open System.Threading.Tasks +open System.Threading +open IcedTasks + +type SyncView<'View> = RouteContext -> INavigable<'View> -> 'View +type AsyncView<'View> = RouteContext -> INavigable<'View> -> Async<'View> + +type TaskView<'View> = + RouteContext -> INavigable<'View> -> CancellationToken -> Task<'View> + +type SyncGuard = RouteContext voption -> RouteContext -> GuardResponse +type AsyncGuard = RouteContext voption -> RouteContext -> Async + +type TaskGuard = + RouteContext voption + -> RouteContext + -> CancellationToken + -> Task + + +type Route = + + static member inline define<'View> + ( + name, + path, + [] handler: RouteContext -> INavigable<'View> -> 'View + ) = + { + name = name + pattern = path + getContent = + GetView<'View>(fun ctx nav -> cancellableValueTask { + return handler ctx nav + }) + canActivate = [] + canDeactivate = [] + cacheStrategy = Cache + } + + static member inline define<'View> + ( + name, + path, + [] handler: + RouteContext -> INavigable<'View> -> CancellationToken -> Task<'View> + ) = + { + name = name + pattern = path + getContent = + GetView<'View>(fun ctx nav -> cancellableValueTask { + let! token = CancellableValueTask.getCancellationToken() + return! handler ctx nav token + }) + canActivate = [] + canDeactivate = [] + cacheStrategy = Cache + } + + static member inline define<'View> + ( + name, + path: string, + [] handler: + RouteContext -> INavigable<'View> -> Async<'View> + ) = + { + name = name + pattern = path + getContent = + GetView<'View>(fun ctx nav -> cancellableValueTask { + return! handler ctx nav + }) + canActivate = [] + canDeactivate = [] + cacheStrategy = Cache + } + + +module Route = + + let inline cache strategy definition : RouteDefinition<_> = { + definition with + cacheStrategy = strategy + } + + let inline canActivate + ([] guard: + RouteContext voption -> RouteContext -> GuardResponse) + definition + = + { + definition with + canActivate = + RouteGuard<'View>(fun activeCtx nextCtx -> cancellableValueTask { + return guard activeCtx nextCtx + }) + :: definition.canActivate + } + + let inline canActivateTask + ([] guard: + RouteContext voption + -> RouteContext + -> CancellationToken + -> Task) + definition + : RouteDefinition<_> = + { + definition with + canActivate = + RouteGuard<'View>(fun activeCtx nextCtx -> cancellableValueTask { + let! token = CancellableValueTask.getCancellationToken() + return! guard activeCtx nextCtx token + }) + :: definition.canActivate + } + + let inline canActivateAsync + ([] guard: + RouteContext voption -> RouteContext -> Async) + definition + : RouteDefinition<_> = + { + definition with + canActivate = + RouteGuard<'View>(fun activeCtx nextCtx -> cancellableValueTask { + return! guard activeCtx nextCtx + }) + :: definition.canActivate + } + + let inline canDeactivate + ([] guard: + RouteContext voption -> RouteContext -> GuardResponse) + definition + = + { + definition with + canDeactivate = + RouteGuard<'View>(fun activeCtx nextCtx -> cancellableValueTask { + return guard activeCtx nextCtx + }) + :: definition.canDeactivate + } + + + let inline canDeactivateTask + ([] guard: + RouteContext voption + -> RouteContext + -> CancellationToken + -> Task) + definition + : RouteDefinition<_> = + { + definition with + canDeactivate = + RouteGuard<'View>(fun activeCtx nextCtx -> cancellableValueTask { + let! token = CancellableValueTask.getCancellationToken() + return! guard activeCtx nextCtx token + }) + :: definition.canDeactivate + } + + let inline canDeactivateAsync + ([] guard: + RouteContext voption -> RouteContext -> Async) + definition + : RouteDefinition<_> = + { + definition with + canDeactivate = + RouteGuard<'View>(fun activeCtx nextCtx -> cancellableValueTask { + return! guard activeCtx nextCtx + }) + :: definition.canDeactivate + } diff --git a/src/Navs/DSL.fsi b/src/Navs/DSL.fsi index 53bee62..df96690 100644 --- a/src/Navs/DSL.fsi +++ b/src/Navs/DSL.fsi @@ -1,110 +1,103 @@ -namespace Navs - -open System.Threading -open System.Threading.Tasks - -[] -type Route = - - ///Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// The view to render when the route is activated - /// A route definition - static member inline define<'View> : - name: string * path: string * [] handler: (RouteContext -> INavigable<'View> -> 'View) -> - RouteDefinition<'View> - - /// Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// An task returning function to render when the route is activated. - /// A route definition - /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. - static member inline define<'View> : - name: string * - path: string * - [] handler: (RouteContext -> INavigable<'View> -> CancellationToken -> Task<'View>) -> - RouteDefinition<'View> - - /// Defines a route in the application - /// The name of the route - /// A templated URL that will be used to match this route - /// An async returning function to render when the route is activated. - /// A route definition - /// - /// A cancellation token can be extracted from Async.CancellationToken in the async workdflow - /// to support cancellation of the route activation. - /// - static member inline define<'View> : - name: string * path: string * [] handler: (RouteContext -> INavigable<'View> -> Async<'View>) -> - RouteDefinition<'View> - - /// - /// Takes a route definition and adds it as a child of the parent route definition. - /// - /// The child route definition - /// The parent route definition - /// The parent route definition with the child route definition added - static member inline child: child: RouteDefinition<'a> -> definition: RouteDefinition<'a> -> RouteDefinition<'a> - - /// - /// Takes a sequence of route definitions and adds them as children of the parent route definition. - /// - /// The child route definition - /// The parent route definition - /// The parent route definition with the child route definition added - static member inline children: - children: RouteDefinition<'a> seq -> definition: RouteDefinition<'a> -> RouteDefinition<'a> - - /// - /// This function allows you to define if the route can be restored from an in memory cache or - /// if it should be always re-rendered when activated. - /// - static member inline cache: strategy: CacheStrategy -> definition: RouteDefinition<'a> -> RouteDefinition<'a> - -module Route = - - /// - /// A Task function to define if a route can be activated. - /// - /// A function that returns a task of boolean - /// The route definition - /// The route definition with the guard added - val inline canActivateTask: - [] guard: (RouteContext -> INavigable<'a> -> CancellationToken -> Task) -> - definition: RouteDefinition<'a> -> - RouteDefinition<'a> - - /// - /// A function to define if a route can be activated. - /// - /// A function that returns a boolean - /// The route definition - /// The route definition with the guard added - val inline canActivate: - [] guard: (RouteContext -> INavigable<'a> -> Async) -> - definition: RouteDefinition<'a> -> - RouteDefinition<'a> - - /// - /// A Task function to define if a route can be deactivated. - /// - /// A function that returns a task of boolean. - /// The route definition - /// The route definition with the guard added - val inline canDeactivateTask: - [] guard: (RouteContext -> INavigable<'a> -> CancellationToken -> Task) -> - definition: RouteDefinition<'a> -> - RouteDefinition<'a> - - /// - /// A Task function to define if a route can be deactivated. - /// - /// A function that returns a boolean - /// The route definition - /// The route definition with the guard added - val inline canDeactivate: - [] guard: (RouteContext -> INavigable<'a> -> Async) -> - definition: RouteDefinition<'a> -> - RouteDefinition<'a> +namespace Navs + +open System.Threading +open System.Threading.Tasks + +type SyncView<'View> = RouteContext -> INavigable<'View> -> 'View +type AsyncView<'View> = RouteContext -> INavigable<'View> -> Async<'View> +type TaskView<'View> = RouteContext -> INavigable<'View> -> CancellationToken -> Task<'View> +type SyncGuard = RouteContext voption -> RouteContext -> GuardResponse +type AsyncGuard = RouteContext voption -> RouteContext -> Async +type TaskGuard = RouteContext voption -> RouteContext -> CancellationToken -> Task + +[] +type Route = + + ///Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// The view to render when the route is activated + /// A route definition + static member inline define<'View> : + name: string * path: string * [] handler: SyncView<'View> -> RouteDefinition<'View> + + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// An task returning function to render when the route is activated. + /// A route definition + /// A cancellation token is provided alongside the route context to allow you to support cancellation of the route activation. + static member inline define<'View> : + name: string * path: string * [] handler: TaskView<'View> -> RouteDefinition<'View> + + /// Defines a route in the application + /// The name of the route + /// A templated URL that will be used to match this route + /// An async returning function to render when the route is activated. + /// A route definition + /// + /// A cancellation token can be extracted from Async.CancellationToken in the async workdflow + /// to support cancellation of the route activation. + /// + static member inline define<'View> : + name: string * path: string * [] handler: AsyncView<'View> -> RouteDefinition<'View> + +module Route = + /// + /// This function allows you to define if the route can be restored from an in memory cache or + /// if it should be always re-rendered when activated. + /// + val inline cache: strategy: CacheStrategy -> definition: RouteDefinition<'a> -> RouteDefinition<'a> + + /// + /// A function to define if a route can be activated. + /// + /// A function that returns a task of boolean + /// The route definition + /// The route definition with the guard added + val inline canActivate: [] guard: SyncGuard -> definition: RouteDefinition<'a> -> RouteDefinition<'a> + + /// + /// A Task function to define if a route can be activated. + /// + /// A function that returns a task of boolean + /// The route definition + /// The route definition with the guard added + val inline canActivateTask: + [] guard: TaskGuard -> definition: RouteDefinition<'a> -> RouteDefinition<'a> + + /// + /// A function to define if a route can be activated. + /// + /// A function that returns a boolean + /// The route definition + /// The route definition with the guard added + val inline canActivateAsync: + [] guard: AsyncGuard -> definition: RouteDefinition<'a> -> RouteDefinition<'a> + + /// + /// A function to define if a route can be activated. + /// + /// A function that returns a task of boolean + /// The route definition + /// The route definition with the guard added + val inline canDeactivate: + [] guard: SyncGuard -> definition: RouteDefinition<'a> -> RouteDefinition<'a> + + /// + /// A Task function to define if a route can be deactivated. + /// + /// A function that returns a task of boolean. + /// The route definition + /// The route definition with the guard added + val inline canDeactivateTask: + [] guard: TaskGuard -> definition: RouteDefinition<'a> -> RouteDefinition<'a> + + /// + /// A Task function to define if a route can be deactivated. + /// + /// A function that returns a boolean + /// The route definition + /// The route definition with the guard added + val inline canDeactivateAsync: + [] guard: AsyncGuard -> definition: RouteDefinition<'a> -> RouteDefinition<'a> diff --git a/src/Navs/History.fs b/src/Navs/History.fs deleted file mode 100644 index e7f21d6..0000000 --- a/src/Navs/History.fs +++ /dev/null @@ -1,62 +0,0 @@ -namespace Navs - -open System.Collections.Generic -open FsToolkit.ErrorHandling - -[] -type IHistoryManager<'HistoryEntry> = - abstract member CanGoBack: bool - abstract member CanGoForward: bool - abstract member SetCurrent: 'HistoryEntry -> unit - - abstract member Current: 'HistoryEntry voption - - abstract member Next: unit -> 'HistoryEntry voption - abstract member Previous: unit -> 'HistoryEntry voption - - -type HistoryManager<'HistoryEntry>(?historySize: int) = - let historySize = defaultArg historySize 10 - - let history = LinkedList<'HistoryEntry>() - let forwardHistory = LinkedList<'HistoryEntry>() - - interface IHistoryManager<'HistoryEntry> with - - member val CanGoBack = history.Count > 1 with get - member val CanGoForward = forwardHistory.Count > 0 with get - - member _.Current = - history.Last - |> ValueOption.ofNull - |> ValueOption.map(fun value -> value.Value) - - member _.SetCurrent(route: 'HistoryEntry) = - if history.Count >= historySize then - history.RemoveFirst() |> ignore - - history.AddLast(route) |> ignore - forwardHistory.Clear() - - member _.Next() = - if forwardHistory.Count <= 0 then - ValueNone - else - let next = forwardHistory.Last.Value - history.AddLast(next) |> ignore - forwardHistory.RemoveLast() - - if history.Count >= historySize then - history.RemoveFirst() - - ValueSome next - - member _.Previous() = - if history.Count <= 0 then - ValueNone - else - let previous = history.Last.Value - forwardHistory.AddLast(previous) |> ignore - history.RemoveLast() - - ValueSome previous diff --git a/src/Navs/History.fsi b/src/Navs/History.fsi deleted file mode 100644 index 324d8b7..0000000 --- a/src/Navs/History.fsi +++ /dev/null @@ -1,17 +0,0 @@ -namespace Navs - -[] -type internal IHistoryManager<'HistoryEntry> = - abstract member CanGoBack: bool - abstract member CanGoForward: bool - - abstract member SetCurrent: 'HistoryEntry -> unit - - abstract member Current: 'HistoryEntry voption - - abstract member Next: unit -> 'HistoryEntry voption - abstract member Previous: unit -> 'HistoryEntry voption - -type internal HistoryManager<'HistoryEntry> = - new: ?historySize: int -> HistoryManager<'HistoryEntry> - interface IHistoryManager<'HistoryEntry> diff --git a/src/Navs/Navs.fsproj b/src/Navs/Navs.fsproj index 47c2f4b..3f358cd 100644 --- a/src/Navs/Navs.fsproj +++ b/src/Navs/Navs.fsproj @@ -1,12 +1,11 @@ - + - net8.0;net9.0 + net10.0;net8.0 + enable - - @@ -15,8 +14,15 @@ - - + + + + + + + + + diff --git a/src/Navs/Router.fs b/src/Navs/Router.fs index 720723b..5b60c4f 100644 --- a/src/Navs/Router.fs +++ b/src/Navs/Router.fs @@ -1,759 +1,586 @@ -namespace Navs.Router - -open System -open System.Collections.Generic -open System.Runtime.InteropServices - -open FsToolkit.ErrorHandling - -open FSharp.Data.Adaptive - -open UrlTemplates.RouteMatcher -open UrlTemplates.UrlParser -open UrlTemplates.UrlTemplate - -open Navs -open System.Threading.Tasks - -[] -type ActiveRouteParams = { - SegmentIndex: int - ParamName: string - ParamValue: string -} - -[] -type RoutingEnv<'View> = { - routes: RouteTrack<'View> seq - state: cval - viewCache: cmap - activeRoute: - cval< - voption<(RouteContext * (RouteGuard<'View> * RouteDefinition<'View>) list)> - > - content: cval> -} - -[] -type ParamResoluton<'View> = { - nextRouteNodes: RouteTrack<'View> seq - nextRouteParams: ActiveRouteParams list - nextContext: RouteContext - routeHit: RouteTrack<'View> -} - -[] -type RouteResolution<'View> = { - view: 'View - canDeactivateGuards: (RouteGuard<'View> * RouteDefinition<'View>) list -} - -[] -type Guards<'View> = { - canActivate: list * RouteDefinition<'View>> - canDeactivate: list * RouteDefinition<'View>> -} - -[] -type Redirection = { from: string; target: string } - -module Dictionary = - - let areEqual (a: IDictionary<_, _>) (b: IDictionary<_, _>) = - if a = b then - true - else if a.Count <> b.Count then - false - else - a - |> Seq.forall(fun (KeyValue(k, v)) -> - match b.TryGetValue k with - | true, v' -> v = v' - | _ -> false - ) - - - -module RouteTracks = - - let rec internal processChildren pattern parent children = - match children with - | [] -> [] - | child :: rest -> - let childTrack = { - pathPattern = $"{pattern}/{child.pattern}" - routeDefinition = child - parentTrack = parent - children = [] - } - - { - childTrack with - children = - processChildren - $"{pattern}/{child.pattern}" - (ValueSome childTrack) - child.children - } - :: processChildren pattern parent rest - - let internal getDefinition - currentPattern - (parent: RouteTrack<'View> voption) - (track: RouteDefinition<'View>) - = - let queue = - Queue< - string * - RouteTrack<'View> voption * - RouteDefinition<'View> * - RouteTrack<'View> list - >() - - let result = ResizeArray>() - - queue.Enqueue(currentPattern, parent, track, []) - - while queue.Count > 0 do - let currentPattern, parent, track, siblings = queue.Dequeue() - - let pattern = - if currentPattern = "" then - track.pattern - else if parent.IsSome && currentPattern.EndsWith('/') then - $"{currentPattern}{track.pattern}" - else - $"{currentPattern}/{track.pattern}" - - let currentTrack = { - pathPattern = pattern - routeDefinition = track - parentTrack = parent - children = siblings - } - - result.Add currentTrack - - let childrenTracks = - processChildren pattern (ValueSome currentTrack) track.children - - for childTrack in childrenTracks do - queue.Enqueue( - pattern, - ValueSome currentTrack, - childTrack.routeDefinition, - childTrack.children - ) - - result - - [] - let fromDefinitions (routes: RouteDefinition<'View> seq) = [ - for route in routes do - yield! getDefinition "" ValueNone route - ] - -module RoutingEnv = - - let get<'View> - (routes: RouteDefinition<'View> seq, splash: (unit -> 'View) option) - = - let routes = RouteTracks.fromDefinitions routes - - { - routes = routes - state = cval Idle - viewCache = cmap() - activeRoute = cval(ValueNone) - content = - cval(splash |> ValueOption.ofOption |> ValueOption.map(fun f -> f())) - } - -module Result = - let inline requireValueSome (msg: string) (value: 'a voption) = - match value with - | ValueSome a -> Ok a - | ValueNone -> Error msg - -module RouteInfo = - - let getParamDiff (urlInfo: UrlInfo) (tplInfo: UrlTemplate) = - if urlInfo.Segments.Length <> tplInfo.Segments.Length then - [] - else - urlInfo.Segments - |> List.zip tplInfo.Segments - |> List.indexed - |> List.choose(fun (index, (segment, urlSegment)) -> - match segment with - | ParamSegment(name, _) -> - Some( - { - SegmentIndex = index - ParamName = name - ParamValue = urlSegment - } - ) - | _ -> None - ) - - let digUpToRoot (track: RouteTrack<'View>) = - let queue = Queue>() - let result = ResizeArray>() - - queue.Enqueue(track) - - while queue.Count > 0 do - let currentTrack = queue.Dequeue() - result.Add currentTrack - - match currentTrack.parentTrack with - | ValueSome parent -> queue.Enqueue(parent) - | ValueNone -> () - - result - - - let getActiveRouteInfo (routes: RouteTrack<'View> seq) (url: string) = result { - let! activeGraph, routeContext = - voption { - let! track, matchInfo = - routes - |> Seq.tryPick(fun route -> - match RouteMatcher.matchStrings route.pathPattern url with - | Ok(template, urlinfo, matchInfo) -> - Some( - route, - {| - Route = url - UrlInfo = urlinfo - UrlMatch = matchInfo - UrlTemplate = template - |} - ) - | Error whytho -> None - ) - - let tracks = digUpToRoot track - return tracks, matchInfo - } - |> Result.requireValueSome "No matching route found" - - let urlParam = getParamDiff routeContext.UrlInfo routeContext.UrlTemplate - - return - activeGraph, - urlParam, - { - path = url - urlInfo = routeContext.UrlInfo - urlMatch = routeContext.UrlMatch - } - } - - let extractGuards (activeGraph: RouteTrack<'View> seq) = - - let canActivate = Stack * RouteDefinition<'View>>() - let canDeactivate = Queue * RouteDefinition<'View>>() - - activeGraph - |> Seq.iter(fun route -> - for guard in route.routeDefinition.canActivate do - canActivate.Push(guard, route.routeDefinition) - - for guard in route.routeDefinition.canDeactivate do - canDeactivate.Enqueue(guard, route.routeDefinition) - ) - - { - canActivate = [ - while canActivate.Count > 0 do - canActivate.Pop() - ] - canDeactivate = [ - while canDeactivate.Count > 0 do - canDeactivate.Dequeue() - ] - } - -module Navigable = - - let runGuards<'View> - (navigable: INavigable<'View>) - (onStop: string -> NavigationError<'View>) - (guards: (RouteGuard<'View> * RouteDefinition<'View>) list) - nextContext - = - async { - let! token = Async.CancellationToken - - return! - guards - |> List.traverseAsyncResultM(fun (guard, definition) -> asyncResult { - if token.IsCancellationRequested then - return! Error NavigationCancelled - else - let! result = guard nextContext navigable token |> Async.AwaitTask - - match result with - | Continue -> return () - | Stop -> return! Error(onStop definition.name) - | Redirect url -> return! Error(GuardRedirect url) - - }) - |> AsyncResult.ignore - } - - let canDeactivate (nav: ref>) activeContext = async { - match activeContext |> AVal.force with - | ValueNone -> return Ok() - | ValueSome(activeContext, activeGuards) -> - - let! result = - runGuards nav.Value CantDeactivate activeGuards activeContext - - match result with - | Error(GuardRedirect _) -> - return Error(CantDeactivate activeContext.path) - | value -> return value - } - - let canActivate (nav: ref>) (nextContext, activeRouteNodes) = asyncResult { - let guards = RouteInfo.extractGuards activeRouteNodes - - do! runGuards nav.Value CantActivate guards.canActivate nextContext - - return guards - } - - let tryGetFromCache - (liveNodes: cmap) - = - fun (key, nextRouteParams, nextUrlInfo: UrlInfo) -> - match liveNodes.TryGetValue key with - | Some(oldParams, oldUrlInfo, oldView) -> - if nextRouteParams = oldParams then - if Dictionary.areEqual oldUrlInfo.Query nextUrlInfo.Query then - ValueSome oldView - else - ValueNone - else - ValueNone - | None -> ValueNone - - let resolveView tryGetFromCache (navigable: ref>) = - fun paramResolution -> asyncResult { - - let renderView = - fun ctx -> asyncResult { - let! token = Async.CancellationToken - - do! - token.IsCancellationRequested - |> Result.requireFalse NavigationCancelled - - let! result = - paramResolution.routeHit.routeDefinition.getContent - ctx - navigable.Value - token - |> Async.AwaitTask - |> Async.Catch - - match result with - | Choice1Of2 view -> return view - | Choice2Of2 ex -> return! Error NavigationCancelled - } - - // 5. Will Render view - match paramResolution.routeHit.routeDefinition.cacheStrategy with - | NoCache -> - let! view = renderView paramResolution.nextContext - - return view - | Cache -> - match - tryGetFromCache( - paramResolution.routeHit.pathPattern, - paramResolution.nextRouteParams, - paramResolution.nextContext.urlInfo - ) - with - | ValueSome view -> return view - | ValueNone -> - let! view = renderView paramResolution.nextContext - - return view - } - - let resolveParams routingEnv url = asyncResult { - - let! nextRouteNodes, nextRouteParams, nextContext = - RouteInfo.getActiveRouteInfo routingEnv.routes url - |> Result.mapError(fun _ -> RouteNotFound url) - - let! routeHit = - nextRouteNodes - // The first route is the currently active route - // The rest of the routes are the matched "parent" routes. - |> Seq.tryHead - |> Result.requireSome(RouteNotFound url) - - return { - nextRouteNodes = nextRouteNodes - nextRouteParams = nextRouteParams - nextContext = nextContext - routeHit = routeHit - } - } - - type private CanDeactivateExecutor<'View> = - (voption * RouteDefinition<'View>>>) cval - -> Async>> - - type private CanActivateExecutor<'View> = - RouteContext * seq> - -> Async, NavigationError<'View>>> - - type private ResolveViewExecutor<'View> = - ParamResoluton<'View> -> Async>> - - let navigateByUrl - ( - routingEnv, - resolveParams, - resolveView: ResolveViewExecutor<'View>, - canActivate: CanActivateExecutor<'View>, - canDeactivate: CanDeactivateExecutor<'View> - ) = - fun (url: string) -> asyncResult { - let! token = Async.CancellationToken - - // 1. Can Deactivate - do! canDeactivate routingEnv.activeRoute - - // Navigation cancelled is not part of lifecycle - do! - token.IsCancellationRequested |> Result.requireFalse NavigationCancelled - - // 2. Resolve URl and Parameters - let! resolveParams = - resolveParams url - |> AsyncResult.teeError(fun error -> - transact(fun _ -> routingEnv.activeRoute.Value <- ValueNone) - ) - - // 3. Can Activate - let! guards = - canActivate(resolveParams.nextContext, resolveParams.nextRouteNodes) - - // 4. Render View - let! rendered = resolveView resolveParams - - match resolveParams.routeHit.routeDefinition.cacheStrategy with - | NoCache -> - transact(fun _ -> - routingEnv.activeRoute.Value <- - ValueSome(resolveParams.nextContext, guards.canDeactivate) - - routingEnv.content.Value <- ValueSome rendered - ) - | Cache -> - transact(fun _ -> - routingEnv.viewCache.Add( - resolveParams.routeHit.pathPattern, - (resolveParams.nextRouteParams, - resolveParams.nextContext.urlInfo, - rendered) - ) - |> ignore - - routingEnv.activeRoute.Value <- - ValueSome(resolveParams.nextContext, guards.canDeactivate) - - routingEnv.content.Value <- ValueSome rendered - ) - } - - let navigateByName routingEnv nav redirectionStack = - fun name routeParams -> asyncResult { - let tryGetFromCache = tryGetFromCache routingEnv.viewCache - let resolveParams = resolveParams routingEnv - let resolveView = resolveView tryGetFromCache nav - let canActivate = canActivate nav - let canDeactivate = canDeactivate nav - - let navigateByUrl = - navigateByUrl( - routingEnv, - resolveParams, - resolveView, - canActivate, - canDeactivate - ) - - let routeParams: IReadOnlyDictionary = - routeParams - // Guess what! double check for NRTs that may come from dotnet langs/types - |> Option.map(fun p -> p |> Option.ofNull) - |> Option.flatten - |> Option.defaultWith(fun _ -> Dictionary()) - - let! route = - routingEnv.routes - |> Seq.tryFind(fun route -> route.routeDefinition.name = name) - |> Result.requireSome(RouteNotFound name) - - let! url = - UrlTemplate.toUrl route.pathPattern routeParams - |> Result.mapError(fun e -> RouteNotFound(String.concat ", " e)) - - return! navigateByUrl url - } - - let get<'View> routingEnv = - let navigable = ref Unchecked.defaultof> - let tryGetFromCache = tryGetFromCache routingEnv.viewCache - let resolveParams = resolveParams routingEnv - let resolveView = resolveView tryGetFromCache navigable - let canActivate = canActivate navigable - let canDeactivate = canDeactivate navigable - - let navigateByUrl = - navigateByUrl( - routingEnv, - resolveParams, - resolveView, - canActivate, - canDeactivate - ) - - navigable.Value <- - { new INavigable<'View> with - - override _.State = routingEnv.state - - override _.StateSnapshot = routingEnv.state |> AVal.force - - override _.Navigate(url, ?cancellationToken) = task { - let redirectionStack = Stack() - - try - transact(fun _ -> routingEnv.state.Value <- Navigating) - - try - let! result = - Async.StartImmediateAsTask( - navigateByUrl url, - ?cancellationToken = cancellationToken - ) - - let mutable lastResult = result - - match result with - | Ok _ -> () - | Error(GuardRedirect redirectTo) -> - redirectionStack.Push({ from = url; target = redirectTo }) - | Error _ -> redirectionStack.Clear() - - while redirectionStack.Count > 0 do - let { from = from; target = target } = redirectionStack.Pop() - - let! result = - Async.StartImmediateAsTask( - navigateByUrl target, - ?cancellationToken = cancellationToken - ) - - lastResult <- result - - match result with - | Ok _ -> () - | Error(GuardRedirect redirectTo) -> - - if target = redirectTo then - () - else - redirectionStack.Push( - { from = from; target = redirectTo } - ) - | Error _ -> redirectionStack.Clear() - - return lastResult - - with - | :? TaskCanceledException - | :? OperationCanceledException -> - transact(fun _ -> routingEnv.activeRoute.Value <- ValueNone) - - return Error NavigationCancelled - | ex -> - transact(fun _ -> routingEnv.activeRoute.Value <- ValueNone) - - return Error(NavigationFailed ex.Message) - - finally - transact(fun _ -> routingEnv.state.Value <- Idle) - } - - override _.NavigateByName(name, ?routeParams, ?cancellationToken) = task { - let redirectionStack = Stack() - - try - transact(fun _ -> routingEnv.state.Value <- Navigating) - - try - let! result = - Async.StartImmediateAsTask( - navigateByName - routingEnv - navigable - redirectionStack - name - routeParams, - ?cancellationToken = cancellationToken - ) - - let mutable lastResult = result - - match result with - | Ok _ -> () - | Error(GuardRedirect redirectTo) -> - redirectionStack.Push({ from = name; target = redirectTo }) - | Error _ -> redirectionStack.Clear() - - while redirectionStack.Count > 0 do - let { from = from; target = target } = redirectionStack.Pop() - - let! result = - Async.StartImmediateAsTask( - navigateByUrl target, - ?cancellationToken = cancellationToken - ) - - lastResult <- result - - match result with - | Ok _ -> () - | Error(GuardRedirect redirectTo) -> - - if from = from && target = redirectTo then - () - else - redirectionStack.Push( - { from = from; target = redirectTo } - ) - | Error _ -> redirectionStack.Clear() - - return lastResult - with - | :? TaskCanceledException - | :? OperationCanceledException -> - transact(fun _ -> routingEnv.activeRoute.Value <- ValueNone) - - return Error NavigationCancelled - | ex -> - transact(fun _ -> routingEnv.activeRoute.Value <- ValueNone) - - return Error(NavigationFailed ex.Message) - finally - transact(fun _ -> routingEnv.state.Value <- Idle) - } - } - - navigable.Value - -[] -type Router = - - static member get<'View>(env: RoutingEnv<'View>, nav: INavigable<'View>) = - { new IRouter<'View> with - - member _.State = nav.State - - member _.StateSnapshot = nav.StateSnapshot - - member _.Route = - env.activeRoute - |> AVal.map( - function - | ValueSome v -> ValueSome(fst v) - | ValueNone -> ValueNone - ) - - member _.RouteSnapshot = - env.activeRoute - |> AVal.map( - function - | ValueSome v -> ValueSome(fst v) - | ValueNone -> ValueNone - ) - |> AVal.force - - member _.ContentSnapshot = env.content |> AVal.force - - member _.Content = env.content - - member _.Navigate(url, ?cancellationToken) = - nav.Navigate(url, ?cancellationToken = cancellationToken) - - member _.NavigateByName(name, ?routeParams, ?cancellationToken) = - nav.NavigateByName( - name, - ?routeParams = routeParams, - ?cancellationToken = cancellationToken - ) - } - - /// - /// Creates a new router with the provided routes. - /// - /// The routes that the router will use to match the URL and render the view - /// - /// The router initially doesn't have a view to render. You can provide this function - /// to supply a splash-like (like mobile devices initial screen) view to render while you trigger the first navigation. - /// - [] - static member get<'View>(routes, [] ?splash) = - let env = RoutingEnv.get<'View>(routes, splash) - - let navigable = Navigable.get env - - - { new IRouter<'View> with - - member _.State = navigable.State - - member _.StateSnapshot = navigable.StateSnapshot - - member _.Route = - env.activeRoute - |> AVal.map( - function - | ValueSome(v, _) -> ValueSome v - | ValueNone -> ValueNone - ) - - member _.RouteSnapshot = - env.activeRoute - |> AVal.map( - function - | ValueSome(v, _) -> ValueSome v - | ValueNone -> ValueNone - ) - |> AVal.force - - member _.ContentSnapshot = env.content |> AVal.force - - member _.Content = env.content - - member _.Navigate(url, ?cancellationToken) = - navigable.Navigate(url, ?cancellationToken = cancellationToken) - - member _.NavigateByName(name, ?routeParams, ?cancellationToken) = - navigable.NavigateByName( - name, - ?routeParams = routeParams, - ?cancellationToken = cancellationToken - ) - } +namespace Navs.Router + +open System +open System.Collections.Generic +open System.Collections.Concurrent +open System.Runtime.InteropServices +open System.Threading + +open Microsoft.Extensions.Logging + +open FSharp.Data.Adaptive +open IcedTasks +open FsToolkit.ErrorHandling + +open UrlTemplates.RouteMatcher +open UrlTemplates.UrlParser +open UrlTemplates.UrlTemplate + +open Navs + +[] +type ActiveRouteParams = { + SegmentIndex: int + ParamName: string + ParamValue: string +} + +type RouteDisposables() = + let disposables = ResizeArray() + + interface IDisposableBag with + member _.AddDisposable(disposable: IDisposable) = + disposables.Add(disposable) + + member _.Dispose() : unit = + for disposable in disposables do + try + disposable.Dispose() + with _ -> + () + +module RouteInfo = + + [] + type RouteUnit<'View> = { + definition: RouteDefinition<'View> + activeParams: ActiveRouteParams list + context: RouteContext + } + + type Redirect = { from: string; target: string } + + let getParamDiff (urlInfo: UrlInfo) (tplInfo: UrlTemplate) = + if urlInfo.Segments.Length <> tplInfo.Segments.Length then + [] + else + List.zip tplInfo.Segments urlInfo.Segments + |> List.indexed + |> List.choose(fun (index, (segment, urlSegment)) -> + match segment with + | ParamSegment(name, _) -> + Some( + { + SegmentIndex = index + ParamName = name + ParamValue = urlSegment + } + ) + | _ -> None + ) + + let getActiveRouteInfo (routes: RouteDefinition<'View> list) (url: string) = result { + let! activeRoute, routeContext = + routes + |> List.tryPick(fun route -> + result { + let! template, urlInfo, matchInfo = + RouteMatcher.matchStrings route.pattern url + + return + route, + {| + Route = url + UrlInfo = urlInfo + UrlMatch = matchInfo + UrlTemplate = template + |} + } + |> Option.ofResult + ) + |> Result.requireSome "No matching route found" + + let urlParam = getParamDiff routeContext.UrlInfo routeContext.UrlTemplate + + return { + definition = activeRoute + activeParams = urlParam + context = { + path = url + urlInfo = routeContext.UrlInfo + urlMatch = routeContext.UrlMatch + disposables = new RouteDisposables() + } + } + } + +module Navigable = + open System.Collections.Immutable + open RouteInfo + + [] + type RouteEnvironment<'View> = { + routes: RouteDefinition<'View> list + state: NavigationState + cache: IDictionary * 'View> + activeRoute: RouteUnit<'View> voption + } + + let navigate + (logger: ILogger) + url + (env: RouteEnvironment<'View>) + (nav: INavigable<'View>) + (stack: Redirect voption) + (token: CancellationToken) + = + taskResult { + // if we have this in cache, let's jumpthe dance + match env.cache.TryGetValue url with + | true, value -> + logger.LogDebug("Cache hit for url: {url}", url) + logger.LogTrace("Returning {value}", value) + return value + // otherwise let's try to resolve the issue + | false, _ -> + // 1. resolve the route + + if token.IsCancellationRequested then + return! Error(NavigationCancelled) + else + let! nextRoute = + RouteInfo.getActiveRouteInfo env.routes url + |> Result.mapError(fun _ -> RouteNotFound url) + + logger.LogDebug("Resolved route: {route}", nextRoute.context.path) + // 2. check deactivation guards + match env.activeRoute with + | ValueSome active -> + logger.LogTrace("Running guards") + + do! + active.definition.canDeactivate + |> List.traverseTaskResultM(fun guard -> taskResult { + if token.IsCancellationRequested then + return! Error(NavigationCancelled) + else + match! + guard.Invoke + (ValueSome active.context, nextRoute.context) + token + with + | Continue -> return () + | Redirect redirect -> + match stack with + | ValueSome { from = from; target = target } -> + logger.LogTrace( + "Guard redirect to {redirect}", + { from = from; target = target } + ) + + if from <> url && redirect = target then + logger.LogDebug( + "{from} is different to {url} and redirect is {redirect}, allowing navigation", + from, + url, + redirect + ) + + return () + else + logger.LogDebug( + "Guard redirect to {redirect}", + redirect + ) + + return! Error(GuardRedirect redirect) + | ValueNone -> return! Error(GuardRedirect redirect) + | Stop -> + logger.LogDebug( + "Guard stop for {path}", + active.context.path + ) + + return! Error(CantDeactivate active.definition.pattern) + }) + |> TaskResult.ignore + + logger.LogTrace("Deactivation guards passed") + // 2.1. Check Next Route Activation Guards with active route + logger.LogTrace("Checking activation guards for next route") + + do! + nextRoute.definition.canActivate + |> List.traverseTaskResultM(fun guard -> taskResult { + if token.IsCancellationRequested then + return! Error(NavigationCancelled) + else + match! + guard.Invoke + (ValueSome active.context, nextRoute.context) + token + with + | Continue -> + logger.LogTrace("Guard passed, continuing") + return () + | Redirect url -> + logger.LogTrace("Guard redirect to {url}", url) + return! Error(GuardRedirect url) + | Stop -> + logger.LogDebug( + "Guard stop for {name} - {pattern}", + nextRoute.definition.name, + nextRoute.definition.pattern + ) + + return! Error(CantActivate nextRoute.definition.pattern) + }) + |> TaskResult.ignore + | ValueNone -> + // 2.1 Check Next Route Activation Guards without active route + do! + nextRoute.definition.canActivate + |> List.traverseTaskResultM(fun guard -> taskResult { + if token.IsCancellationRequested then + return! Error(NavigationCancelled) + else + match! guard.Invoke (ValueNone, nextRoute.context) token with + | Continue -> + logger.LogTrace("Guard passed, continuing") + return () + | Redirect url -> + logger.LogTrace("Guard redirect to {url}", url) + return! Error(GuardRedirect url) + | Stop -> + logger.LogDebug( + "Guard stop for {name} - {pattern}", + nextRoute.definition.name, + nextRoute.definition.pattern + ) + + return! Error(CantActivate nextRoute.definition.pattern) + }) + |> TaskResult.ignore + + if token.IsCancellationRequested then + return! Error(NavigationCancelled) + else + // 3. Resolve the view content + let! resolved = + nextRoute.definition.getContent.Invoke + (nextRoute.context, nav) + token + + // 4. Deactivate the previous active route now that activation + // guards and content resolution have passed. Disposing earlier + // would destroy resources before we know the new route is valid. + env.activeRoute + |> ValueOption.iter(fun active -> + match active.definition.cacheStrategy with + | NoCache -> + logger.LogDebug("No cache strategy, disposing active route") + active.context.disposables.Dispose() + | Cache -> + logger.LogDebug("Cache strategy, not disposing active route") + ) + + match nextRoute.definition.cacheStrategy with + | NoCache -> + logger.LogDebug("No cache strategy, resolving content") + return nextRoute, resolved + | Cache -> + logger.LogDebug("Cache strategy, checking cache for content") + // 3.2 If we're here, it means this url is not in the cache and we need to resolve it + + match env.cache.TryAdd(url, (nextRoute, resolved)) with + | true -> + logger.LogTrace( + "Cache miss for url: {url}, adding to cache", + url + ) + + () // Yeah we're good + | false -> + logger.LogTrace( + "Cache hit for url: {url}, not adding to cache", + url + ) + // Why though? + () + + logger.LogDebug( + "Resolved view for {nextRouteContext}", + nextRoute.context + ) + + return nextRoute, resolved + } + + let (|IsNavigationCancelled|_|) (error: NavigationError<_>) = + error.IsNavigationCancelled + + [] + let (|IsRedirection|_|) (error: NavigationError<_>) = + match error with + | GuardRedirect route -> ValueSome route + | _ -> ValueNone + + let attemptRedirect + (env, nav, logger: ILogger) + origin + target + (maxCyclicRedirects: int) + = + cancellableTask { + let mutable lastResult = ValueNone + let mutable lastError = ValueNone + let mutable lastRedirect = ValueNone + let redirectStack = Stack() + let visited = Collections.Generic.HashSet() + redirectStack.Push({ from = origin; target = target }) + let! token = CancellableTask.getCancellationToken() + + while redirectStack.Count > 0 do + if visited.Count >= maxCyclicRedirects then + logger.LogError( + "Exceeded the maximum number of cyclic redirects ({maxCyclicRedirects})", + maxCyclicRedirects + ) + + lastError <- + ValueSome( + NavigationFailed + $"Exceeded the maximum number of cyclic redirects (%d{maxCyclicRedirects})" + ) + + redirectStack.Clear() + else + logger.LogTrace("Redirect Stack Count: {count}", redirectStack.Count) + let redirect = redirectStack.Pop() + lastRedirect <- ValueSome redirect + + if visited.Contains(redirect.from, redirect.target) then + logger.LogError( + "Detected a redirect cycle from {from} to {target}", + redirect.from, + redirect.target + ) + + lastError <- + ValueSome( + NavigationFailed + $"Detected a redirect cycle from %s{redirect.from} to %s{redirect.target}" + ) + + redirectStack.Clear() + else + visited.Add(redirect.from, redirect.target) |> ignore + + let! result = + navigate logger redirect.target env nav (ValueSome redirect) token + + match result with + | Error(IsRedirection route) -> + let stackEntry = { + from = redirect.target + target = route + } + + redirectStack.Push(stackEntry) + logger.LogTrace("ReNavigation Redirect: {stackEntry}", stackEntry) + + lastError <- ValueNone + | Error errors -> + logger.LogTrace("ReNavigation Error: {errors}", errors) + lastError <- ValueSome errors + redirectStack.Clear() + | Ok result -> + logger.LogTrace("ReNavigation success: {result}", result) + lastResult <- ValueSome result + + if lastError.IsSome then + return Error lastError.Value + else + match lastResult with + | ValueNone -> + match lastRedirect with + | ValueSome { from = origin; target = target } -> + return + Error( + NavigationFailed + $"Unable to resolve route, last redirect attempt from %s{origin} to %s{target}" + ) + | ValueNone -> + return Error(NavigationFailed "Unable to resolve route") + | ValueSome result -> return Ok result + } + +[] +type Router = + + [] + static member build<'View> + ( + routes: RouteDefinition<'View> seq, + [] ?splash: unit -> 'View, + [] ?logger: ILogger, + [] ?maxCyclicRedirects: int + ) = + let routes = routes |> Seq.toList + let state = cval Idle + let maxCyclicRedirects = defaultArg maxCyclicRedirects 5 + + let cache = + ConcurrentDictionary * 'View>() + + let activeRoute: RouteInfo.RouteUnit<'View> voption cval = cval ValueNone + + let logger = + match logger with + | Some logger -> logger + | None -> + let lf = + LoggerFactory.Create(fun builder -> + builder.AddConsole() |> ignore +#if DEBUG + builder.SetMinimumLevel(LogLevel.Debug) |> ignore +#else + builder.SetMinimumLevel(LogLevel.Warning) |> ignore +#endif + ) + + lf.CreateLogger() + + let activeView = + cval(splash |> Option.map(fun f -> f()) |> ValueOption.ofOption) + + { new IRouter<'View> with + member _.State = state :> aval + member _.StateSnapshot = state |> AVal.force + + member this.Navigate(url, ?cancellationToken) = taskResult { + let token = defaultArg cancellationToken CancellationToken.None + + use _ = + token.Register(fun _ -> transact(fun _ -> state.Value <- Idle)) + + transact(fun _ -> state.Value <- Navigating) + + logger.LogTrace("Navigation State set to {navstate}", state.Value) + + let env: Navigable.RouteEnvironment<'View> = { + routes = routes + cache = cache + state = state |> AVal.force + activeRoute = activeRoute |> AVal.force + } + + logger.LogTrace("Route Environment built: {routeEnv}", env) + + logger.LogDebug( + "RouteEnv ActiveRoute: {activeRoute}", + env.activeRoute + ) + + let nav = this :> INavigable<'View> + + let attemptRedirect = Navigable.attemptRedirect(env, nav, logger) + + if token.IsCancellationRequested then + return! Error(NavigationCancelled) + + try + let! resolved = task { + let! result = + Navigable.navigate logger url env nav ValueNone token + + match result with + | Ok value -> return Ok value + | Error(Navigable.IsRedirection route) -> + return! attemptRedirect url route maxCyclicRedirects token + | Error error -> return Error error + } + + if token.IsCancellationRequested then + return! Error(NavigationCancelled) + + let route, view = resolved + + transact(fun _ -> + activeRoute.Value <- ValueSome route + activeView.Value <- ValueSome view + state.Value <- Idle + ) + + logger.LogDebug( + "Navigation completed to route: {route}", + route.context.path, + view + ) + + return () + with + | :? Tasks.TaskCanceledException -> + transact(fun _ -> state.Value <- Idle) + logger.LogWarning("Navigation cancelled by token") + return! Error NavigationCancelled + | err -> + transact(fun _ -> state.Value <- Idle) + + logger.LogTrace( + "Navigation failed with error: {error}", + err.Message + ) + + return! Error(NavigationFailed err.Message) + } + + member this.NavigateByName + (routeName, ?routeParams, ?cancellationToken) + = + taskResult { + let token = defaultArg cancellationToken CancellationToken.None + + let! foundRoute = + routes + |> Seq.tryFind(fun r -> r.name = routeName) + |> Result.requireSome(RouteNotFound routeName) + + logger.LogTrace( + "Navigating to route: {route}", + $"{foundRoute.name} - {foundRoute.pattern}" + ) + + let! url = + result { + match routeParams with + | Some p -> + let! url = UrlTemplate.toUrl foundRoute.pattern p + + logger.LogTrace( + "Route Params Supplied, resolved url: {url}", + url + ) + + return url + | None -> + let! url = + UrlTemplate.toUrl foundRoute.pattern (Dictionary<_, _>()) + + logger.LogTrace( + "Route Params not supplied, resolved url: {url}", + url + ) + + return url + } + |> Result.mapError(fun errors -> + NavigationFailed(errors |> String.concat ", ") + ) + + if token.IsCancellationRequested then + return! Error(NavigationCancelled) + else + return! this.Navigate(url, token) + } + + member _.Route = + activeRoute + |> AVal.map(fun v -> v |> ValueOption.map(fun v -> v.context)) + + member _.RouteSnapshot = + activeRoute |> AVal.force |> ValueOption.map(fun v -> v.context) + + member _.Content = activeView + member _.ContentSnapshot = activeView |> AVal.force + } diff --git a/src/Navs/Router.fsi b/src/Navs/Router.fsi index 80cd27d..a0307e4 100644 --- a/src/Navs/Router.fsi +++ b/src/Navs/Router.fsi @@ -1,44 +1,46 @@ -namespace Navs.Router - -open System.Runtime.InteropServices -open FSharp.Data.Adaptive -open UrlTemplates.UrlParser - -open Navs - -/// -/// This object contains parameters and its values -/// that are extracted from the URL when a route is activated. -/// -/// -/// Note that every parameter here is a string as it is extracted from the URL. -/// As it has not been processed by the route yet. -/// -[] -type ActiveRouteParams = - { SegmentIndex: int - ParamName: string - ParamValue: string } - -/// -/// This object is a container for the routing operations performed by INavigable -/// interface. -/// -[] -type internal RoutingEnv<'View> = - { routes: RouteTrack<'View> seq - state: cval - viewCache: cmap - activeRoute: cval * RouteDefinition<'View>) list)>> - content: cval> } - - -[] -type Router = - - /// - /// Get an instance of IRouter with the given routes. - /// and optionally a splash screen. - /// - [] - static member get: routes: RouteDefinition<'View> seq * [] ?splash: (unit -> 'View) -> IRouter<'View> +namespace Navs.Router + +open System.Runtime.InteropServices +open Microsoft.Extensions.Logging +open Navs + +/// +/// This object contains parameters and its values +/// that are extracted from the URL when a route is activated. +/// +/// +/// Note that every parameter here is a string as it is extracted from the URL. +/// As it has not been processed by the route yet. +/// +[] +type ActiveRouteParams = + { SegmentIndex: int + ParamName: string + ParamValue: string } + +[] +type Router = + + /// + /// Get an instance of IRouter with the given routes. + /// and optionally a splash screen. + /// + /// The route definitions the router will use to match URLs and render views. + /// + /// An optional function that produces a view to render while the first + /// navigation is triggered, before any route has been activated. + /// + /// An optional logger used to trace the router's activity. + /// + /// Maximum number of distinct (from, target) redirect pairs the router will + /// follow while resolving a single navigation. Defaults to 5. This guards + /// against redirect cycles; it does not cap the length of linear redirect + /// chains, which are expected to terminate on their own. + /// + [] + static member build: + routes: RouteDefinition<'View> seq * + [] ?splash: (unit -> 'View) * + [] ?logger: ILogger * + [] ?maxCyclicRedirects: int -> + IRouter<'View> diff --git a/src/Navs/Types.fs b/src/Navs/Types.fs index 271e888..a8981d5 100644 --- a/src/Navs/Types.fs +++ b/src/Navs/Types.fs @@ -1,110 +1,135 @@ -namespace Navs - -open System -open System.Threading -open System.Threading.Tasks -open System.Runtime.InteropServices -open System.Collections.Generic -open FSharp.Data.Adaptive -open UrlTemplates.RouteMatcher -open UrlTemplates.UrlParser - -type RouteContext = { - [] - path: string - [] - urlMatch: UrlMatch - [] - urlInfo: UrlInfo -} - -[] -type NavigationError<'View> = - | NavigationCancelled - | RouteNotFound of url: string - | NavigationFailed of message: string - | CantDeactivate of deactivatedRoute: string - | CantActivate of activatedRoute: string - | GuardRedirect of redirectTo: string - -[] -type NavigationState = - | Idle - | Navigating - -[] -type INavigable<'View> = - - abstract member State: aval - - abstract member StateSnapshot: NavigationState - - abstract member Navigate: - url: string * [] ?cancellationToken: CancellationToken -> - Task>> - - abstract member NavigateByName: - routeName: string * - [] ?routeParams: IReadOnlyDictionary * - [] ?cancellationToken: CancellationToken -> - Task>> - -[] -type IRouter<'View> = - inherit INavigable<'View> - - abstract member Route: aval - - abstract member RouteSnapshot: RouteContext voption - - abstract member Content: aval<'View voption> - - abstract member ContentSnapshot: 'View voption - - -[] -type GuardResponse = - | Continue - | Stop - | Redirect of url: string - -type RouteGuard<'View> = - RouteContext -> INavigable<'View> -> CancellationToken -> Task - -type GetView<'View> = - RouteContext -> INavigable<'View> -> CancellationToken -> Task<'View> - -[] -type CacheStrategy = - | NoCache - | Cache - -[] -type RouteDefinition<'View> = { - [] - name: string - [] - pattern: string - [] - getContent: GetView<'View> - [] - children: RouteDefinition<'View> list - [] - canActivate: RouteGuard<'View> list - [] - canDeactivate: RouteGuard<'View> list - [] - cacheStrategy: CacheStrategy -} - -[] -type RouteTrack<'View> = { - [] - pathPattern: string - [] - routeDefinition: RouteDefinition<'View> - [] - parentTrack: RouteTrack<'View> voption - [] - children: RouteTrack<'View> list -} +namespace Navs + +open System +open System.Threading +open System.Threading.Tasks +open System.Runtime.InteropServices +open System.Runtime.CompilerServices +open System.Collections.Generic +open FSharp.Data.Adaptive +open UrlTemplates.RouteMatcher +open UrlTemplates.UrlParser +open IcedTasks + +[] +type IDisposableBag = + inherit IDisposable + abstract AddDisposable: IDisposable -> unit + +[] +type RouteContext = { + [] + path: string + [] + urlMatch: UrlMatch + [] + urlInfo: UrlInfo + [] + disposables: IDisposableBag + +} with + + [] + member this.addDisposable disposable = + this.disposables.AddDisposable disposable + +type NavigationError<'View> = + | SameRouteNavigation + | NavigationCancelled + | RouteNotFound of url: string + | NavigationFailed of message: string + | CantDeactivate of deactivatedRoute: string + | CantActivate of activatedRoute: string + | GuardRedirect of redirectTo: string + +[] +type NavigationState = + | Idle + | Navigating + +[] +type INavigable<'View> = + + abstract member State: aval + + abstract member StateSnapshot: NavigationState + + abstract member Navigate: + url: string * [] ?cancellationToken: CancellationToken -> + Task>> + + abstract member NavigateByName: + routeName: string * + [] ?routeParams: IReadOnlyDictionary * + [] ?cancellationToken: CancellationToken -> + Task>> + +[] +type IRouter<'View> = + inherit INavigable<'View> + + abstract member Route: aval + + abstract member RouteSnapshot: RouteContext voption + + abstract member Content: aval<'View voption> + + abstract member ContentSnapshot: 'View voption + + +[] +type GuardResponse = + | Continue + | Stop + | Redirect of url: string + +type RouteGuard<'View> = + delegate of + RouteContext voption * RouteContext -> CancellableValueTask + +type GetView<'View> = + delegate of RouteContext * INavigable<'View> -> CancellableValueTask<'View> + +[] +type CacheStrategy = + | NoCache + | Cache + +[] +type RouteDefinition<'View> = { + [] + name: string + [] + pattern: string + [] + getContent: GetView<'View> + [] + canActivate: RouteGuard<'View> list + [] + canDeactivate: RouteGuard<'View> list + [] + cacheStrategy: CacheStrategy +} + +[] +type RouteContextExtensions() = + + [] + static member inline getParam(ctx: RouteContext, name: string) = + UrlMatch.getFromParams name ctx.urlMatch + + [] + static member inline getParamSequence(ctx: RouteContext, name: string) = + UrlMatch.getParamSeqFromQuery name ctx.urlMatch + |> ValueOption.defaultValue Seq.empty + +module RouteContext = + let inline addDisposable disposable (ctx: RouteContext) = + ctx.addDisposable disposable + + let inline getParam (name: string) (ctx: RouteContext) = + UrlMatch.getFromParams name ctx.urlMatch + + let inline getParamSequence (name: string) (ctx: RouteContext) = + UrlMatch.getParamSeqFromQuery name ctx.urlMatch + |> ValueOption.defaultValue Seq.empty diff --git a/src/Navs/Types.fsi b/src/Navs/Types.fsi index c3a33af..161a2de 100644 --- a/src/Navs/Types.fsi +++ b/src/Navs/Types.fsi @@ -1,210 +1,273 @@ -namespace Navs - -open System -open System.Threading -open System.Threading.Tasks -open System.Runtime.InteropServices -open System.Collections.Generic -open FSharp.Data.Adaptive -open UrlTemplates.RouteMatcher -open UrlTemplates.UrlParser - -/// -/// The context of the route that is being activated. -/// This can be used to extract the parameters from the URL and extract information -/// about the templated route that is being activated. -/// -type RouteContext = - { - /// RAW URL that is being activated - [] - path: string - /// An object that contains multiple dictionaries with the parameters - /// that were extracted from the URL either from the url parameters - /// the query string or the hash portion of the URL. - [] - urlMatch: UrlMatch - /// An object that contains the segments, query and hash of the URL in a string form. - [] - urlInfo: UrlInfo - } - -/// -/// This object contains the contextual information about why a navigation -/// could not be performed. -/// -[] -type NavigationError<'View> = - | NavigationCancelled - | RouteNotFound of url: string - | NavigationFailed of message: string - | CantDeactivate of deactivatedRoute: string - | CantActivate of activatedRoute: string - | GuardRedirect of redirectTo: string - -[] -type NavigationState = - | Idle - | Navigating - -[] -type INavigable<'View> = - - /// - /// The state of the router. - /// - /// - /// This adaptive value will emit the current state of the router. - /// It will emit Navigating when the router is in the process of navigating to a route. - /// It will emit Idle when the router is not navigating to a route. - /// - abstract member State: aval - - /// - /// The current state of the router. - /// - /// - /// This property will return the current state of the router. - /// It will return Navigating when the router is in the process of navigating to a route. - /// It will return Idle when the router is not navigating to a route. - /// - abstract member StateSnapshot: NavigationState - - /// - /// Performs a navigation to the route that matches the URL. - /// - /// The URL to navigate to - /// A token that can be used to cancel the navigation - /// - /// A task that will complete when the navigation is successful or when it fails. - /// - abstract member Navigate: - url: string * [] ?cancellationToken: CancellationToken -> Task>> - - /// - /// Performs a navigation by the name of the route. - /// - /// The name of the route to navigate to - /// The parameters that will be used to match the route - /// A token that can be used to cancel the navigation - /// - /// A task that will complete when the navigation is successful or when it fails. - /// - /// - /// The route name is the name that was used to define the route in the route definition. - /// - /// Please note that any required parameters not supplied in the routeParams will make the navigation fail. - /// - abstract member NavigateByName: - routeName: string * - [] ?routeParams: IReadOnlyDictionary * - [] ?cancellationToken: CancellationToken -> - Task>> - -[] -type IRouter<'View> = - inherit INavigable<'View> - - /// - /// The current route that is being rendered by the router. - /// - /// - /// This adaptive value will emit the current route that is being rendered by the router. - /// It will also however emit None when the router is in a state where it doesn't have a route to render, - /// this could be when the router is just starting up and hasn't navigated to any route yet or when the router - /// failed to navigate. - /// - abstract member Route: aval - - /// - /// The current route that is being rendered by the router. - /// - /// - /// This property will return the current route that is being rendered by the router. - /// It will also however return None when the router is in a state where it doesn't have a route to render, - /// this could be when the router is just starting up and hasn't navigated to any route yet or when the router - /// failed to navigate. - /// - abstract member RouteSnapshot: RouteContext voption - - /// - /// The current route that is being rendered by the router. - /// - /// - /// This adaptive value will emit the current route that is being rendered by the router. - /// - abstract member Content: aval<'View voption> - - /// - /// The current route that is being rendered by the router. - /// - /// - /// This is a single value that will return the current route that is being rendered by the router. - /// - abstract member ContentSnapshot: 'View voption - -[] -type GuardResponse = - | Continue - | Stop - | Redirect of url: string - -/// An alias for a function that takes a route context and a cancellation token -/// In order to determine if the route can be activated/deactivated or not. -type RouteGuard<'View> = RouteContext -> INavigable<'View> -> CancellationToken -> Task - -/// An alias for a function that takes a route context and a cancellation token -/// in order to extract the view that will be rendered when the route is activated. -type GetView<'View> = RouteContext -> INavigable<'View> -> CancellationToken -> Task<'View> - -/// The strategy that the router will use to cache the views that are rendered -/// when the route is activated. -[] -type CacheStrategy = - /// The Cache strategy makes that the rendered view will be stored in memory - /// and will be re-used when the route is activated again. - | NoCache - /// The NoCache strategy makes that the rendered view will be re-rendered - /// every time the route is activated. - | Cache - -[] -type RouteDefinition<'View> = - { - /// Name used to locate this route for "by-name" route activation - [] - name: string - /// The URL pattern that will be used to match the route and enforce - /// the URL's parameters to be extracted and passed to the view. - [] - pattern: string - /// The delegate that will be called to render the view when the route is activated. - [] - getContent: GetView<'View> - /// The children routes that this route contains. - [] - children: RouteDefinition<'View> list - /// The guards that will be executed when the route is activated. - /// If any of them returns false, the route will not be activated. - [] - canActivate: RouteGuard<'View> list - /// The guards that will be executed when the route is deactivated. - /// If any of them returns false, the route will not be deactivated. - [] - canDeactivate: RouteGuard<'View> list - /// The strategy that the router will use to cache the views that are rendered - /// when the route is activated. - [] - cacheStrategy: CacheStrategy - } - -/// This is an object used to keep track of the routes that are defined in the application. -/// and contextual information about the route that is being activated. -/// This is used to match the URL and render the view. -/// It also contains the children routes that are defined in the application. -[] -type internal RouteTrack<'View> = - { pathPattern: string - routeDefinition: RouteDefinition<'View> - parentTrack: RouteTrack<'View> voption - children: RouteTrack<'View> list } +namespace Navs + +open System +open System.Threading +open System.Threading.Tasks +open System.Runtime.InteropServices +open System.Runtime.CompilerServices +open System.Collections.Generic +open IcedTasks +open FSharp.Data.Adaptive +open UrlTemplates.RouteMatcher +open UrlTemplates.UrlParser + +/// +/// An object that contains multiple disposable objects that can be disposed of +/// when the route is not cached and deactivated. +/// +[] +type IDisposableBag = + inherit IDisposable + abstract AddDisposable: IDisposable -> unit + +/// +/// The context of the route that is being activated. +/// This can be used to extract the parameters from the URL and extract information +/// about the templated route that is being activated. +/// +[] +type RouteContext = + { + /// RAW URL that is being activated + [] + path: string + /// An object that contains multiple dictionaries with the parameters + /// that were extracted from the URL either from the url parameters + /// the query string or the hash portion of the URL. + [] + urlMatch: UrlMatch + /// An object that contains the segments, query and hash of the URL in a string form. + [] + urlInfo: UrlInfo + /// An object that contains objects that should be disposed of when the route is deactivated. + [] + disposables: IDisposableBag + } + + [] + member addDisposable: IDisposable -> unit + +/// +/// This object contains the contextual information about why a navigation +/// could not be performed. +/// +type NavigationError<'View> = + | SameRouteNavigation + | NavigationCancelled + | RouteNotFound of url: string + | NavigationFailed of message: string + | CantDeactivate of deactivatedRoute: string + | CantActivate of activatedRoute: string + | GuardRedirect of redirectTo: string + +[] +type NavigationState = + | Idle + | Navigating + +[] +type INavigable<'View> = + + /// + /// The state of the router. + /// + /// + /// This adaptive value will emit the current state of the router. + /// It will emit Navigating when the router is in the process of navigating to a route. + /// It will emit Idle when the router is not navigating to a route. + /// + abstract member State: aval + + /// + /// The current state of the router. + /// + /// + /// This property will return the current state of the router. + /// It will return Navigating when the router is in the process of navigating to a route. + /// It will return Idle when the router is not navigating to a route. + /// + abstract member StateSnapshot: NavigationState + + /// + /// Performs a navigation to the route that matches the URL. + /// + /// The URL to navigate to + /// A token that can be used to cancel the navigation + /// + /// A task that will complete when the navigation is successful or when it fails. + /// + abstract member Navigate: + url: string * [] ?cancellationToken: CancellationToken -> Task>> + + /// + /// Performs a navigation by the name of the route. + /// + /// The name of the route to navigate to + /// The parameters that will be used to match the route + /// A token that can be used to cancel the navigation + /// + /// A task that will complete when the navigation is successful or when it fails. + /// + /// + /// The route name is the name that was used to define the route in the route definition. + /// + /// Please note that any required parameters not supplied in the routeParams will make the navigation fail. + /// + abstract member NavigateByName: + routeName: string * + [] ?routeParams: IReadOnlyDictionary * + [] ?cancellationToken: CancellationToken -> + Task>> + +[] +type IRouter<'View> = + inherit INavigable<'View> + + /// + /// The current route that is being rendered by the router. + /// + /// + /// This adaptive value will emit the current route that is being rendered by the router. + /// It will also however emit None when the router is in a state where it doesn't have a route to render, + /// this could be when the router is just starting up and hasn't navigated to any route yet or when the router + /// failed to navigate. + /// + abstract member Route: aval + + /// + /// The current route that is being rendered by the router. + /// + /// + /// This property will return the current route that is being rendered by the router. + /// It will also however return None when the router is in a state where it doesn't have a route to render, + /// this could be when the router is just starting up and hasn't navigated to any route yet or when the router + /// failed to navigate. + /// + abstract member RouteSnapshot: RouteContext voption + + /// + /// The current route that is being rendered by the router. + /// + /// + /// This adaptive value will emit the current route that is being rendered by the router. + /// + abstract member Content: aval<'View voption> + + /// + /// The current route that is being rendered by the router. + /// + /// + /// This is a single value that will return the current route that is being rendered by the router. + /// + abstract member ContentSnapshot: 'View voption + +[] +type GuardResponse = + | Continue + | Stop + | Redirect of url: string + +/// An alias for a function that takes a route context and a cancellation token +/// In order to determine if the route can be activated/deactivated or not. +type RouteGuard<'View> = delegate of RouteContext voption * RouteContext -> CancellableValueTask + +/// An alias for a function that takes a route context and a cancellation token +/// in order to extract the view that will be rendered when the route is activated. +type GetView<'View> = delegate of RouteContext * INavigable<'View> -> CancellableValueTask<'View> + +/// The strategy that the router will use to cache the views that are rendered +/// when the route is activated. +[] +type CacheStrategy = + /// The Cache strategy makes that the rendered view will be stored in memory + /// and will be re-used when the route is activated again. + | NoCache + /// The NoCache strategy makes that the rendered view will be re-rendered + /// every time the route is activated. + | Cache + +[] +type RouteDefinition<'View> = + { + /// Name used to locate this route for "by-name" route activation + [] + name: string + /// The URL pattern that will be used to match the route and enforce + /// the URL's parameters to be extracted and passed to the view. + [] + pattern: string + /// The delegate that will be called to render the view when the route is activated. + [] + getContent: GetView<'View> + /// The guards that will be executed when the route is activated. + /// If any of them returns false, the route will not be activated. + [] + canActivate: RouteGuard<'View> list + /// The guards that will be executed when the route is deactivated. + /// If any of them returns false, the route will not be deactivated. + [] + canDeactivate: RouteGuard<'View> list + /// The strategy that the router will use to cache the views that are rendered + /// when the route is activated. + [] + cacheStrategy: CacheStrategy + } + +[] +type RouteContextExtensions = + + /// + /// Gets a parameter from the "path" or the "query" section of the route context. + /// + /// The route context to get the parameter from + /// The name of the parameter to get + /// + /// The parameter value if it exists in the route context and it was succesfully parsed to it's supplied type or None if it doesn't + /// + [] + static member inline getParam<'CastedType> : ctx: RouteContext * name: string -> 'CastedType voption + + /// + /// Gets a parameter from the "query" section of the route context. + /// + /// The route context to get the parameter from + /// The name of the parameter to get + /// + /// The parameter value if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't + /// + /// + /// This method will attempt to collect as many ocurrences of the parameter as it can find in the query string. + /// + [] + static member inline getParamSequence<'CastedType> : ctx: RouteContext * name: string -> 'CastedType seq + +module RouteContext = + + /// + /// Adds a disposable object to the route context. + /// + /// This object will be disposed of when the route is deactivated ONLY if the route doesn't have cache enabled + val inline addDisposable: IDisposable -> RouteContext -> unit + + /// + /// Gets a parameter from the "path" or the "query" section of the route context. + /// + /// The name of the parameter to get + /// The route context to get the parameter from + /// + /// The parameter value if it exists in the route context and it was succesfully parsed to it's supplied type or None if it doesn't + /// + val inline getParam<'CastedType> : name: string -> ctx: RouteContext -> 'CastedType voption + + /// + /// Gets a parameter from the "query" section of the route context. + /// + /// The name of the parameter to get + /// The route context to get the parameter from + /// + /// The parameter value if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't + /// + /// + /// This method will attempt to collect as many ocurrences of the parameter as it can find in the query string. + /// + val inline getParamSequence<'CastedType> : name: string -> ctx: RouteContext -> 'CastedType seq diff --git a/tests/UrlTemplates.Tests/Main.fs b/src/UrlTemplates.Tests/Main.fs similarity index 100% rename from tests/UrlTemplates.Tests/Main.fs rename to src/UrlTemplates.Tests/Main.fs diff --git a/tests/UrlTemplates.Tests/RouteMatcher.fs b/src/UrlTemplates.Tests/RouteMatcher.fs similarity index 96% rename from tests/UrlTemplates.Tests/RouteMatcher.fs rename to src/UrlTemplates.Tests/RouteMatcher.fs index f117b37..a20664a 100644 --- a/tests/UrlTemplates.Tests/RouteMatcher.fs +++ b/src/UrlTemplates.Tests/RouteMatcher.fs @@ -314,7 +314,7 @@ module MatchStrings = let actualTemplate = "/hello/world" let actualUrl = "/hello/world" - let (urlTemplate, _, _) = + let urlTemplate, _, _ = RouteMatcher.matchStrings actualTemplate actualUrl |> Result.defaultWith(fun e -> failtestf "Expected Ok, got Error %A" e @@ -384,7 +384,7 @@ module MatchStrings = let guid = System.Guid.NewGuid() let actualUrl = $"/hello/{guid}?name=john&age=30" - let (urlTemplate, urlInfo, urlMatch) = + let urlTemplate, urlInfo, urlMatch = RouteMatcher.matchStrings actualTemplate actualUrl |> Result.defaultWith(fun e -> failtestf "Expected Ok, got Error %A" e diff --git a/tests/UrlTemplates.Tests/UrlTemplates.Tests.fsproj b/src/UrlTemplates.Tests/UrlTemplates.Tests.fsproj similarity index 56% rename from tests/UrlTemplates.Tests/UrlTemplates.Tests.fsproj rename to src/UrlTemplates.Tests/UrlTemplates.Tests.fsproj index 5f7e76e..dacbf4c 100644 --- a/tests/UrlTemplates.Tests/UrlTemplates.Tests.fsproj +++ b/src/UrlTemplates.Tests/UrlTemplates.Tests.fsproj @@ -2,8 +2,9 @@ Exe - net8.0 + net10.0;net8.0 false + false @@ -12,9 +13,10 @@ - - - + + + + diff --git a/src/UrlTemplates/Parsing.fs b/src/UrlTemplates/Parsing.fs index 8f265b0..f7efa1f 100644 --- a/src/UrlTemplates/Parsing.fs +++ b/src/UrlTemplates/Parsing.fs @@ -1,20 +1,20 @@ -[] -module internal UrlTemplates.Parser.Common - -open FParsec - - -let SegmentDelimiters = [ '/'; '#'; '?' ] - -let QueryDelimiters = [ '='; '&'; '#' ] - -let QuerySeparator: Parser = pchar '&' - -let SegmentSeparator: Parser = pchar '/' - - -let ParamMarker: Parser = pchar ':' - -let QueryMarker: Parser = pchar '?' - -let HashMarker: Parser = pchar '#' +[] +module internal UrlTemplates.Parser.Common + +open FParsec + + +let SegmentDelimiters = [ '/'; '#'; '?' ] + +let QueryDelimiters = [ '='; '&'; '#' ] + +let QuerySeparator: Parser = pchar '&' + +let SegmentSeparator: Parser = pchar '/' + + +let ParamMarker: Parser = pchar ':' + +let QueryMarker: Parser = pchar '?' + +let HashMarker: Parser = pchar '#' diff --git a/src/UrlTemplates/Parsing.fsi b/src/UrlTemplates/Parsing.fsi index dfae78a..f0a02ae 100644 --- a/src/UrlTemplates/Parsing.fsi +++ b/src/UrlTemplates/Parsing.fsi @@ -1,11 +1,11 @@ -[] -module internal UrlTemplates.Parser.Common - -open FParsec -val SegmentDelimiters: char list -val QueryDelimiters: char list -val QuerySeparator: Parser -val SegmentSeparator: Parser -val ParamMarker: Parser -val QueryMarker: Parser -val HashMarker: Parser +[] +module internal UrlTemplates.Parser.Common + +open FParsec +val SegmentDelimiters: char list +val QueryDelimiters: char list +val QuerySeparator: Parser +val SegmentSeparator: Parser +val ParamMarker: Parser +val QueryMarker: Parser +val HashMarker: Parser diff --git a/src/UrlTemplates/RouteMatcher.fs b/src/UrlTemplates/RouteMatcher.fs index 6d212f2..b21cc58 100644 --- a/src/UrlTemplates/RouteMatcher.fs +++ b/src/UrlTemplates/RouteMatcher.fs @@ -1,321 +1,321 @@ -namespace UrlTemplates.RouteMatcher - -open System -open System.Collections.Generic -open System.Runtime.CompilerServices -open FsToolkit.ErrorHandling - -open UrlTemplates.UrlTemplate -open UrlTemplates.UrlParser - - -type QueryParamError = - | MissingRequired of string - | UnparsableQueryItem of - name: string * - expectedType: TypedParam * - value: string - | UnparsableQueryItems of (string * TypedParam * string) list - -type MatchingError = - | SegmentLengthMismatch - | MissingQueryParams of string list - | SegmentMismatch of string * string - | UnparsableParam of name: string * expectedType: TypedParam * value: string - | QueryParamError of QueryParamError - -type StringMatchError = - | TemplateParsingError of string - | UrlParsingError of string - | MatchingError of MatchingError - - -type UrlMatch = { - Params: IReadOnlyDictionary - QueryParams: IReadOnlyDictionary - Hash: string voption -} - -[] -module UrlMatch = - // make sure extensions are visible in VB.NET - [] - do () - - let getParamSeqFromQuery<'CastedType> (name: string) (urlMatch: UrlMatch) = - match urlMatch.QueryParams.TryGetValue name with - | true, value -> - try - let items = unbox> value - items |> Seq.cast<'CastedType> |> ValueSome - with :? InvalidCastException -> - ValueNone - | false, _ -> ValueNone - - let getParamFromQuery<'CastedType> (name: string) (urlMatch: UrlMatch) = - match urlMatch.QueryParams.TryGetValue name with - | true, value -> - try - ValueSome(unbox<'CastedType> value) - with :? InvalidCastException -> - ValueNone - | false, _ -> ValueNone - - - let getParamFromPath<'CastedType> (name: string) (urlMatch: UrlMatch) = - match urlMatch.Params.TryGetValue name with - | true, value -> - try - ValueSome(unbox<'CastedType> value) - with :? InvalidCastException -> - ValueNone - | false, _ -> ValueNone - - let getFromParams<'CastedType> (name: string) (urlMatch: UrlMatch) = - getParamFromPath<'CastedType> name urlMatch - |> ValueOption.orElseWith(fun () -> - getParamFromQuery<'CastedType> name urlMatch - ) - -[] -type UrlMatchExtensions = - - [] - static member inline getParamSeqFromQuery<'CastedType> - (urlMatch: UrlMatch, name: string) - = - UrlMatch.getParamSeqFromQuery<'CastedType> name urlMatch - - [] - static member inline getParamFromQuery<'CastedType> - (urlMatch: UrlMatch, name: string) - = - UrlMatch.getParamFromQuery<'CastedType> name urlMatch - - [] - static member inline getParamFromPath<'CastedType> - (urlMatch: UrlMatch, name: string) - = - UrlMatch.getParamFromPath<'CastedType> name urlMatch - - [] - static member inline getFromParams<'CastedType> - (urlMatch: UrlMatch, name: string) - = - UrlMatch.getFromParams<'CastedType> name urlMatch - - -[] -module RouteMatcher = - - let getKeySize (map: QueryKey list) = - map - |> List.fold - (fun (required, optional) next -> - match next with - | Required _ -> (required + 1, optional) - | Optional _ -> (required, optional + 1) - ) - (0, 0) - - let tryParseValue (tipe: TypedParam) (value: string) : Result = result { - match tipe with - | TypedParam.String -> return value - | Int -> - match Int32.TryParse value with - | true, v -> return v - | false, _ -> return! Error "Could not parse int" - | Float -> - match Double.TryParse value with - | true, v -> return v - | false, _ -> return! Error "Could not parse float" - | Bool -> - match Boolean.TryParse value with - | true, v -> return v - | false, _ -> return! Error "Could not parse bool" - | Guid -> - match Guid.TryParse value with - | true, v -> return v - | false, _ -> return! Error "Could not parse guid" - | Long -> - match Int64.TryParse value with - | true, v -> return v - | false, _ -> return! Error "Could not parse long" - | Decimal -> - match Decimal.TryParse value with - | true, v -> return v - | false, _ -> return! Error "Could not parse decimal" - } - - let fillParamBag (fromTemplate: TemplateSegment list) (fromUrl: string list) = - let addToBag - (templated: TemplateSegment) - (url: string) - (bag: Dictionary) - = - result { - match templated with - | Plain name -> - if name <> url then - return! Error(SegmentMismatch(name, url)) - else - return () - | ParamSegment(name, tipe) -> - let! parsedValue = - tryParseValue tipe url - |> Result.mapError(fun _ -> UnparsableParam(name, tipe, url)) - - bag.Add(name, parsedValue) - return () - } - - let bag = Dictionary() - let errors = ResizeArray() - - for index in 0 .. (fromTemplate.Length - 1) do - let item = fromTemplate[index] - let url = fromUrl[index] - - match addToBag item url bag with - | Ok() -> () - | Error err -> errors.Add err - - if errors.Count <= 0 then - bag |> Ok - else - Error(errors |> List.ofSeq) - - let extractRequired - name - (value: string voption) - tipe - (map: Dictionary<_, _>) - = - result { - let! value = - match value with - | ValueSome value -> Ok value - | ValueNone -> Error(MissingRequired(name)) - - let! parsedValue = - tryParseValue tipe value - |> Result.mapError(fun _ -> UnparsableQueryItem(name, tipe, value)) - - map.Add(name, parsedValue) - return map - } - - let extractOptional - name - (value: string voption) - tipe - (map: Dictionary) - = - result { - match value with - | ValueNone -> return map - | ValueSome value -> - match tryParseValue tipe value with - | Error _ -> return map - | Ok parsedValue -> - map.Add(name, parsedValue) - return map - } - - let extractListValues name tipe values (map: Dictionary) = - match - values - |> List.traverseResultA(fun value -> - tryParseValue tipe value |> Result.mapError(fun _ -> name, tipe, value) - ) - with - | Ok values -> - map.Add(name, values) - Ok map - | Error errs -> errs |> UnparsableQueryItems |> Error - - let fillQueryParams - (templated: QueryKey list) - (url: Dictionary) - = - let bag = Dictionary() - - templated - |> List.fold - (fun current next -> result { - let! current = current - - match next with - | Required(name, tipe) -> - match url.TryGetValue name with - | false, _ -> return! Error(MissingRequired(name)) - | true, String value -> - return! extractRequired name value tipe current - | true, StringValues values -> - return! extractListValues name tipe values current - | Optional(name, tipe) -> - match url.TryGetValue name with - | false, _ -> return current - | true, String value -> - return! extractOptional name value tipe current - | true, StringValues values -> - return! extractListValues name tipe values current - }) - (Ok bag) - - let inline getMissingQueryParams url key = - match key with - | Required(name, _) -> - match url.Query.TryGetValue name with - | false, _ -> Some name - | true, _ -> None - | Optional(_, _) -> None - - let collectMissingQueryParams (template: UrlTemplate) url _ = - template.Query - |> List.choose(getMissingQueryParams url) - |> MissingQueryParams - - let matchTemplate (template: UrlTemplate) (url: UrlInfo) = validation { - let requiredKeysSize, _ = getKeySize template.Query - let urlKeySize = url.Query.Keys.Count - - do! - template.Segments.Length = url.Segments.Length - |> Result.requireTrue SegmentLengthMismatch - - if requiredKeysSize > 0 then - do! - urlKeySize >= requiredKeysSize - |> Result.requireTrue "" - |> Result.mapError(collectMissingQueryParams template url) - - let! urlParams = fillParamBag template.Segments url.Segments - - and! queryParamsBag = - fillQueryParams template.Query url.Query - |> Result.mapError QueryParamError - - return { - Params = urlParams - QueryParams = queryParamsBag - Hash = url.Hash - } - } - - let matchStrings template url = validation { - let! template = - UrlTemplate.ofString template |> Result.mapError TemplateParsingError - - and! url = UrlParser.ofString url |> Result.mapError UrlParsingError - - let! value = matchTemplate template url |> Validation.mapError MatchingError - return template, url, value - } - - let matchUrl template url = validation { - let! url = UrlParser.ofString url |> Result.mapError UrlParsingError - - let! value = matchTemplate template url |> Validation.mapError MatchingError - return url, value - } +namespace UrlTemplates.RouteMatcher + +open System +open System.Collections.Generic +open System.Runtime.CompilerServices +open FsToolkit.ErrorHandling + +open UrlTemplates.UrlTemplate +open UrlTemplates.UrlParser + + +type QueryParamError = + | MissingRequired of string + | UnparsableQueryItem of + name: string * + expectedType: TypedParam * + value: string + | UnparsableQueryItems of (string * TypedParam * string) list + +type MatchingError = + | SegmentLengthMismatch + | MissingQueryParams of string list + | SegmentMismatch of string * string + | UnparsableParam of name: string * expectedType: TypedParam * value: string + | QueryParamError of QueryParamError + +type StringMatchError = + | TemplateParsingError of string + | UrlParsingError of string + | MatchingError of MatchingError + + +type UrlMatch = { + Params: IReadOnlyDictionary + QueryParams: IReadOnlyDictionary + Hash: string voption +} + +[] +module UrlMatch = + // make sure extensions are visible in VB.NET + [] + do () + + let getParamSeqFromQuery<'CastedType> (name: string) (urlMatch: UrlMatch) = + match urlMatch.QueryParams.TryGetValue name with + | true, value -> + try + let items = unbox> value + items |> Seq.cast<'CastedType> |> ValueSome + with :? InvalidCastException -> + ValueNone + | false, _ -> ValueNone + + let getParamFromQuery<'CastedType> (name: string) (urlMatch: UrlMatch) = + match urlMatch.QueryParams.TryGetValue name with + | true, value -> + try + ValueSome(unbox<'CastedType> value) + with :? InvalidCastException -> + ValueNone + | false, _ -> ValueNone + + + let getParamFromPath<'CastedType> (name: string) (urlMatch: UrlMatch) = + match urlMatch.Params.TryGetValue name with + | true, value -> + try + ValueSome(unbox<'CastedType> value) + with :? InvalidCastException -> + ValueNone + | false, _ -> ValueNone + + let getFromParams<'CastedType> (name: string) (urlMatch: UrlMatch) = + getParamFromPath<'CastedType> name urlMatch + |> ValueOption.orElseWith(fun () -> + getParamFromQuery<'CastedType> name urlMatch + ) + +[] +type UrlMatchExtensions = + + [] + static member inline getParamSeqFromQuery<'CastedType> + (urlMatch: UrlMatch, name: string) + = + UrlMatch.getParamSeqFromQuery<'CastedType> name urlMatch + + [] + static member inline getParamFromQuery<'CastedType> + (urlMatch: UrlMatch, name: string) + = + UrlMatch.getParamFromQuery<'CastedType> name urlMatch + + [] + static member inline getParamFromPath<'CastedType> + (urlMatch: UrlMatch, name: string) + = + UrlMatch.getParamFromPath<'CastedType> name urlMatch + + [] + static member inline getFromParams<'CastedType> + (urlMatch: UrlMatch, name: string) + = + UrlMatch.getFromParams<'CastedType> name urlMatch + + +[] +module RouteMatcher = + + let getKeySize (map: QueryKey list) = + map + |> List.fold + (fun (required, optional) next -> + match next with + | Required _ -> (required + 1, optional) + | Optional _ -> (required, optional + 1) + ) + (0, 0) + + let tryParseValue (tipe: TypedParam) (value: string) : Result = result { + match tipe with + | TypedParam.String -> return value + | Int -> + match Int32.TryParse value with + | true, v -> return v + | false, _ -> return! Error "Could not parse int" + | Float -> + match Double.TryParse value with + | true, v -> return v + | false, _ -> return! Error "Could not parse float" + | Bool -> + match Boolean.TryParse value with + | true, v -> return v + | false, _ -> return! Error "Could not parse bool" + | Guid -> + match Guid.TryParse value with + | true, v -> return v + | false, _ -> return! Error "Could not parse guid" + | Long -> + match Int64.TryParse value with + | true, v -> return v + | false, _ -> return! Error "Could not parse long" + | Decimal -> + match Decimal.TryParse value with + | true, v -> return v + | false, _ -> return! Error "Could not parse decimal" + } + + let fillParamBag (fromTemplate: TemplateSegment list) (fromUrl: string list) = + let addToBag + (templated: TemplateSegment) + (url: string) + (bag: Dictionary) + = + result { + match templated with + | Plain name -> + if name <> url then + return! Error(SegmentMismatch(name, url)) + else + return () + | ParamSegment(name, tipe) -> + let! parsedValue = + tryParseValue tipe url + |> Result.mapError(fun _ -> UnparsableParam(name, tipe, url)) + + bag.Add(name, parsedValue) + return () + } + + let bag = Dictionary() + let errors = ResizeArray() + + for index in 0 .. (fromTemplate.Length - 1) do + let item = fromTemplate[index] + let url = fromUrl[index] + + match addToBag item url bag with + | Ok() -> () + | Error err -> errors.Add err + + if errors.Count <= 0 then + bag |> Ok + else + Error(errors |> List.ofSeq) + + let extractRequired + name + (value: string voption) + tipe + (map: Dictionary<_, _>) + = + result { + let! value = + match value with + | ValueSome value -> Ok value + | ValueNone -> Error(MissingRequired(name)) + + let! parsedValue = + tryParseValue tipe value + |> Result.mapError(fun _ -> UnparsableQueryItem(name, tipe, value)) + + map.Add(name, parsedValue) + return map + } + + let extractOptional + name + (value: string voption) + tipe + (map: Dictionary) + = + result { + match value with + | ValueNone -> return map + | ValueSome value -> + match tryParseValue tipe value with + | Error _ -> return map + | Ok parsedValue -> + map.Add(name, parsedValue) + return map + } + + let extractListValues name tipe values (map: Dictionary) = + match + values + |> List.traverseResultA(fun value -> + tryParseValue tipe value |> Result.mapError(fun _ -> name, tipe, value) + ) + with + | Ok values -> + map.Add(name, values) + Ok map + | Error errs -> errs |> UnparsableQueryItems |> Error + + let fillQueryParams + (templated: QueryKey list) + (url: Dictionary) + = + let bag = Dictionary() + + templated + |> List.fold + (fun current next -> result { + let! current = current + + match next with + | Required(name, tipe) -> + match url.TryGetValue name with + | false, _ -> return! Error(MissingRequired(name)) + | true, String value -> + return! extractRequired name value tipe current + | true, StringValues values -> + return! extractListValues name tipe values current + | Optional(name, tipe) -> + match url.TryGetValue name with + | false, _ -> return current + | true, String value -> + return! extractOptional name value tipe current + | true, StringValues values -> + return! extractListValues name tipe values current + }) + (Ok bag) + + let inline getMissingQueryParams url key = + match key with + | Required(name, _) -> + match url.Query.TryGetValue name with + | false, _ -> Some name + | true, _ -> None + | Optional(_, _) -> None + + let collectMissingQueryParams (template: UrlTemplate) url _ = + template.Query + |> List.choose(getMissingQueryParams url) + |> MissingQueryParams + + let matchTemplate (template: UrlTemplate) (url: UrlInfo) = validation { + let requiredKeysSize, _ = getKeySize template.Query + let urlKeySize = url.Query.Keys.Count + + do! + template.Segments.Length = url.Segments.Length + |> Result.requireTrue SegmentLengthMismatch + + if requiredKeysSize > 0 then + do! + urlKeySize >= requiredKeysSize + |> Result.requireTrue "" + |> Result.mapError(collectMissingQueryParams template url) + + let! urlParams = fillParamBag template.Segments url.Segments + + and! queryParamsBag = + fillQueryParams template.Query url.Query + |> Result.mapError QueryParamError + + return { + Params = urlParams + QueryParams = queryParamsBag + Hash = url.Hash + } + } + + let matchStrings template url = validation { + let! template = + UrlTemplate.ofString template |> Result.mapError TemplateParsingError + + and! url = UrlParser.ofString url |> Result.mapError UrlParsingError + + let! value = matchTemplate template url |> Validation.mapError MatchingError + return template, url, value + } + + let matchUrl template url = validation { + let! url = UrlParser.ofString url |> Result.mapError UrlParsingError + + let! value = matchTemplate template url |> Validation.mapError MatchingError + return url, value + } diff --git a/src/UrlTemplates/RouteMatcher.fsi b/src/UrlTemplates/RouteMatcher.fsi index 3ea761f..5fba578 100644 --- a/src/UrlTemplates/RouteMatcher.fsi +++ b/src/UrlTemplates/RouteMatcher.fsi @@ -1,190 +1,190 @@ -namespace UrlTemplates.RouteMatcher - -open System.Collections.Generic -open FsToolkit.ErrorHandling - -open UrlTemplates.UrlTemplate -open UrlTemplates.UrlParser -open System.Runtime.CompilerServices - -type QueryParamError = - | MissingRequired of string - | UnparsableQueryItem of name: string * expectedType: TypedParam * value: string - | UnparsableQueryItems of (string * TypedParam * string) list - -/// The error that can be returned when the URL doesn't match the template -type MatchingError = - | SegmentLengthMismatch - | MissingQueryParams of string list - | SegmentMismatch of string * string - | UnparsableParam of name: string * expectedType: TypedParam * value: string - | QueryParamError of QueryParamError - -type StringMatchError = - | TemplateParsingError of string - | UrlParsingError of string - | MatchingError of MatchingError - -/// The result of a successful match between a URL and a templated URL -type UrlMatch = - { - /// - /// The matched segments of the URL, this will be used to extract the parameters - /// - /// - /// /:id/:name will match /123/john and the segments will be - /// - /// Params["id"] = 123 - /// - /// Params["name"] = "john" - /// - Params: IReadOnlyDictionary - /// - /// The matched query parameters of the URL, this will be used to extract the parameters - /// - /// - /// /users?name&age<int> will match /users?name=john&age=30 - /// - /// QueryParams["name"] = "john" - /// - /// QueryParams["age"] = 30 - /// - QueryParams: IReadOnlyDictionary - - /// - /// The hash of the URL - /// - /// - /// /users#123 will match /users#123 - /// - /// Hash = ValueSome "123" - /// - Hash: string voption - } - -[] -module UrlMatch = - - /// - /// Gets a sequence of parameters from the supplied query key in the URL - /// - /// The name of the parameter to get - /// The match result to get the parameter from - /// - /// The parameter values if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't - /// - val getParamSeqFromQuery<'CastedType> : name: string -> urlMatch: UrlMatch -> 'CastedType seq voption - - - /// - /// Gets a parameter from the query parameters of the URL - /// - /// The name of the parameter to get - /// The match result to get the parameter from - /// - /// The parameter value if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't - /// - val getParamFromQuery<'CastedType> : name: string -> urlMatch: UrlMatch -> 'CastedType voption - - /// - /// Gets a parameter from the path segments of the URL - /// - /// The name of the parameter to get - /// The match result to get the parameter from - /// - /// The parameter value if it exists in the path segments and it was succesfully parsed to it's supplied type or None if it doesn't - /// - val getParamFromPath<'CastedType> : name: string -> urlMatch: UrlMatch -> 'CastedType voption - - /// - /// Gets a parameter from the query parameters or segments of the URL - /// - /// The name of the parameter to get - /// The match result to get the parameter from - /// - /// The parameter value if it exists in the query parameters or path segments and it was succesfully parsed to it's supplied type or None if it doesn't - /// - val getFromParams<'CastedType> : name: string -> urlMatch: UrlMatch -> 'CastedType voption - -[] -type UrlMatchExtensions = - - /// - /// Gets a sequence of parameters from the supplied query key in the URL - /// - /// The name of the parameter to get - /// The match result to get the parameter from - /// - /// The parameter values if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't - /// - [] - static member inline getParamSeqFromQuery<'CastedType> : urlMatch: UrlMatch * name: string -> 'CastedType seq voption - - /// - /// Gets a parameter from the query parameters of the URL - /// - /// The name of the parameter to get - /// The match result to get the parameter from - /// - /// The parameter value if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't - /// - [] - static member inline getParamFromQuery<'CastedType> : urlMatch: UrlMatch * name: string -> 'CastedType voption - - /// - /// Gets a parameter from the path segments of the URL - /// - /// The name of the parameter to get - /// The match result to get the parameter from - /// - /// The parameter value if it exists in the path segments and it was succesfully parsed to it's supplied type or None if it doesn't - /// - [] - static member inline getParamFromPath<'CastedType> : urlMatch: UrlMatch * name: string -> 'CastedType voption - - /// - /// Gets a parameter from the query parameters or segments of the URL - /// - /// The name of the parameter to get - /// The match result to get the parameter from - /// - /// The parameter value if it exists in the query parameters or path segments and it was succesfully parsed to it's supplied type or None if it doesn't - /// - [] - static member inline getFromParams<'CastedType> : urlMatch: UrlMatch * name: string -> 'CastedType voption - -[] -module RouteMatcher = - - /// - /// Matches a URL against a template - /// - /// The template to match the URL against - /// The URL to match - /// - /// A validation that contains the matched URL and the match result if the URL matches the template - /// or a list of MatchingErrors if the URL doesn't match the template. - /// - val matchTemplate: template: UrlTemplate -> url: UrlInfo -> Validation - - /// - /// Matches a URL against a template - /// - /// The template to match the URL against - /// The URL to match - /// - /// A validation that contains the matched URL and the match result if the URL matches the template - /// or a list of MatchingErrors if the URL doesn't match the template. - /// - val matchUrl: template: UrlTemplate -> url: string -> Validation - - /// - /// Matches a URL against a templated string - /// - /// The template to match the URL against - /// The URL to match - /// - /// A validation that contains the UrlTemplate obtained from the provided string, the matched URL, and the match result if the URL matches the template - /// or a list of StringMatchErrors if there were problems parsing the template, the URL or if the URL doesn't match the template. - /// - val matchStrings: template: string -> url: string -> Validation +namespace UrlTemplates.RouteMatcher + +open System.Collections.Generic +open FsToolkit.ErrorHandling + +open UrlTemplates.UrlTemplate +open UrlTemplates.UrlParser +open System.Runtime.CompilerServices + +type QueryParamError = + | MissingRequired of string + | UnparsableQueryItem of name: string * expectedType: TypedParam * value: string + | UnparsableQueryItems of (string * TypedParam * string) list + +/// The error that can be returned when the URL doesn't match the template +type MatchingError = + | SegmentLengthMismatch + | MissingQueryParams of string list + | SegmentMismatch of string * string + | UnparsableParam of name: string * expectedType: TypedParam * value: string + | QueryParamError of QueryParamError + +type StringMatchError = + | TemplateParsingError of string + | UrlParsingError of string + | MatchingError of MatchingError + +/// The result of a successful match between a URL and a templated URL +type UrlMatch = + { + /// + /// The matched segments of the URL, this will be used to extract the parameters + /// + /// + /// /:id/:name will match /123/john and the segments will be + /// + /// Params["id"] = 123 + /// + /// Params["name"] = "john" + /// + Params: IReadOnlyDictionary + /// + /// The matched query parameters of the URL, this will be used to extract the parameters + /// + /// + /// /users?name&age<int> will match /users?name=john&age=30 + /// + /// QueryParams["name"] = "john" + /// + /// QueryParams["age"] = 30 + /// + QueryParams: IReadOnlyDictionary + + /// + /// The hash of the URL + /// + /// + /// /users#123 will match /users#123 + /// + /// Hash = ValueSome "123" + /// + Hash: string voption + } + +[] +module UrlMatch = + + /// + /// Gets a sequence of parameters from the supplied query key in the URL + /// + /// The name of the parameter to get + /// The match result to get the parameter from + /// + /// The parameter values if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't + /// + val getParamSeqFromQuery<'CastedType> : name: string -> urlMatch: UrlMatch -> 'CastedType seq voption + + + /// + /// Gets a parameter from the query parameters of the URL + /// + /// The name of the parameter to get + /// The match result to get the parameter from + /// + /// The parameter value if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't + /// + val getParamFromQuery<'CastedType> : name: string -> urlMatch: UrlMatch -> 'CastedType voption + + /// + /// Gets a parameter from the path segments of the URL + /// + /// The name of the parameter to get + /// The match result to get the parameter from + /// + /// The parameter value if it exists in the path segments and it was succesfully parsed to it's supplied type or None if it doesn't + /// + val getParamFromPath<'CastedType> : name: string -> urlMatch: UrlMatch -> 'CastedType voption + + /// + /// Gets a parameter from the query parameters or segments of the URL + /// + /// The name of the parameter to get + /// The match result to get the parameter from + /// + /// The parameter value if it exists in the query parameters or path segments and it was succesfully parsed to it's supplied type or None if it doesn't + /// + val getFromParams<'CastedType> : name: string -> urlMatch: UrlMatch -> 'CastedType voption + +[] +type UrlMatchExtensions = + + /// + /// Gets a sequence of parameters from the supplied query key in the URL + /// + /// The name of the parameter to get + /// The match result to get the parameter from + /// + /// The parameter values if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't + /// + [] + static member inline getParamSeqFromQuery<'CastedType> : urlMatch: UrlMatch * name: string -> 'CastedType seq voption + + /// + /// Gets a parameter from the query parameters of the URL + /// + /// The name of the parameter to get + /// The match result to get the parameter from + /// + /// The parameter value if it exists in the query parameters and it was succesfully parsed to it's supplied type or None if it doesn't + /// + [] + static member inline getParamFromQuery<'CastedType> : urlMatch: UrlMatch * name: string -> 'CastedType voption + + /// + /// Gets a parameter from the path segments of the URL + /// + /// The name of the parameter to get + /// The match result to get the parameter from + /// + /// The parameter value if it exists in the path segments and it was succesfully parsed to it's supplied type or None if it doesn't + /// + [] + static member inline getParamFromPath<'CastedType> : urlMatch: UrlMatch * name: string -> 'CastedType voption + + /// + /// Gets a parameter from the query parameters or segments of the URL + /// + /// The name of the parameter to get + /// The match result to get the parameter from + /// + /// The parameter value if it exists in the query parameters or path segments and it was succesfully parsed to it's supplied type or None if it doesn't + /// + [] + static member inline getFromParams<'CastedType> : urlMatch: UrlMatch * name: string -> 'CastedType voption + +[] +module RouteMatcher = + + /// + /// Matches a URL against a template + /// + /// The template to match the URL against + /// The URL to match + /// + /// A validation that contains the matched URL and the match result if the URL matches the template + /// or a list of MatchingErrors if the URL doesn't match the template. + /// + val matchTemplate: template: UrlTemplate -> url: UrlInfo -> Validation + + /// + /// Matches a URL against a template + /// + /// The template to match the URL against + /// The URL to match + /// + /// A validation that contains the matched URL and the match result if the URL matches the template + /// or a list of MatchingErrors if the URL doesn't match the template. + /// + val matchUrl: template: UrlTemplate -> url: string -> Validation + + /// + /// Matches a URL against a templated string + /// + /// The template to match the URL against + /// The URL to match + /// + /// A validation that contains the UrlTemplate obtained from the provided string, the matched URL, and the match result if the URL matches the template + /// or a list of StringMatchErrors if there were problems parsing the template, the URL or if the URL doesn't match the template. + /// + val matchStrings: template: string -> url: string -> Validation diff --git a/src/UrlTemplates/UrlParser.fs b/src/UrlTemplates/UrlParser.fs index 8e0121b..0e7bd29 100644 --- a/src/UrlTemplates/UrlParser.fs +++ b/src/UrlTemplates/UrlParser.fs @@ -1,108 +1,108 @@ -namespace UrlTemplates.UrlParser - -open System -open FParsec -open UrlTemplates.Parser -open FsToolkit.ErrorHandling -open System.Collections.Generic - -[] -type QueryValue = - | String of value: string voption - | StringValues of values: string list - -[] -type UrlComponent = - | Segment of segment: string - | Query of query: Dictionary - | Hash of hash: string - -type UrlInfo = { - Segments: string list - Query: Dictionary - Hash: string voption -} - -[] -module UrlParser = - - let segments = - sepEndBy - (manyChars(noneOf Common.SegmentDelimiters)) - Common.SegmentSeparator - - let hash = manyChars anyChar - - let query = - let queryKv = - let separator = pchar '=' - let key = manyChars(noneOf Common.QueryDelimiters) - let value = manyChars(noneOf Common.QueryDelimiters) - - key .>> opt separator .>>. opt value - - let addOrUpdate (nextValue: string option) existing = - match existing with - | Some(String a) -> - - StringValues [ - nextValue |> Option.defaultValue "" - a |> ValueOption.defaultValue "" - ] - - | Some(StringValues values) -> - StringValues((nextValue |> Option.defaultValue "") :: values) - | None -> String(nextValue |> ValueOption.ofOption) - - let tupleListToMap - (current: Dictionary) - (nextKey: string, nextValue: string option) - = - match current.TryGetValue nextKey with - | true, existing -> - current[nextKey] <- addOrUpdate nextValue (Some existing) - current - | false, _ -> - current[nextKey] <- addOrUpdate nextValue None - current - - sepEndBy queryKv Common.QuerySeparator - >>= (fun values -> - values |> List.fold tupleListToMap (Dictionary<_, _>()) |> preturn - ) - - let parse = - segments - .>>. opt(Common.QueryMarker >>. query) - .>>. opt(Common.HashMarker >>. hash) - .>> eof - >>= (fun ((segments, query), hash) -> - let query = query |> Option.defaultValue(Dictionary()) - - { - Segments = - match segments with - | [ ""; "" ] -> [ "" ] - | _ -> segments - Query = query - Hash = hash |> ValueOption.ofOption - } - |> preturn - ) - - let ofUri (uri: Uri) = - let uri = - if uri.IsAbsoluteUri then - uri - else - Uri( - Uri("url+templates://"), - Uri($"{uri.PathAndQuery}{uri.Fragment}", UriKind.Relative) - ) - - $"{uri.PathAndQuery}{uri.Fragment}" - - let ofString (url: string) = - match run parse url with - | Success(result, _, _) -> Result.Ok result - | Failure(err, _, _) -> Result.Error(err) +namespace UrlTemplates.UrlParser + +open System +open FParsec +open UrlTemplates.Parser +open FsToolkit.ErrorHandling +open System.Collections.Generic + +[] +type QueryValue = + | String of value: string voption + | StringValues of values: string list + +[] +type UrlComponent = + | Segment of segment: string + | Query of query: Dictionary + | Hash of hash: string + +type UrlInfo = { + Segments: string list + Query: Dictionary + Hash: string voption +} + +[] +module UrlParser = + + let segments = + sepEndBy + (manyChars(noneOf Common.SegmentDelimiters)) + Common.SegmentSeparator + + let hash = manyChars anyChar + + let query = + let queryKv = + let separator = pchar '=' + let key = manyChars(noneOf Common.QueryDelimiters) + let value = manyChars(noneOf Common.QueryDelimiters) + + key .>> opt separator .>>. opt value + + let addOrUpdate (nextValue: string option) existing = + match existing with + | Some(String a) -> + + StringValues [ + nextValue |> Option.defaultValue "" + a |> ValueOption.defaultValue "" + ] + + | Some(StringValues values) -> + StringValues((nextValue |> Option.defaultValue "") :: values) + | None -> String(nextValue |> ValueOption.ofOption) + + let tupleListToMap + (current: Dictionary) + (nextKey: string, nextValue: string option) + = + match current.TryGetValue nextKey with + | true, existing -> + current[nextKey] <- addOrUpdate nextValue (Some existing) + current + | false, _ -> + current[nextKey] <- addOrUpdate nextValue None + current + + sepEndBy queryKv Common.QuerySeparator + >>= (fun values -> + values |> List.fold tupleListToMap (Dictionary<_, _>()) |> preturn + ) + + let parse = + segments + .>>. opt(Common.QueryMarker >>. query) + .>>. opt(Common.HashMarker >>. hash) + .>> eof + >>= (fun ((segments, query), hash) -> + let query = query |> Option.defaultValue(Dictionary()) + + { + Segments = + match segments with + | [ ""; "" ] -> [ "" ] + | _ -> segments + Query = query + Hash = hash |> ValueOption.ofOption + } + |> preturn + ) + + let ofUri (uri: Uri) = + let uri = + if uri.IsAbsoluteUri then + uri + else + Uri( + Uri("url+templates://"), + Uri($"{uri.PathAndQuery}{uri.Fragment}", UriKind.Relative) + ) + + $"{uri.PathAndQuery}{uri.Fragment}" + + let ofString (url: string) = + match run parse url with + | Success(result, _, _) -> Result.Ok result + | Failure(err, _, _) -> Result.Error(err) diff --git a/src/UrlTemplates/UrlParser.fsi b/src/UrlTemplates/UrlParser.fsi index cf9820d..4590e36 100644 --- a/src/UrlTemplates/UrlParser.fsi +++ b/src/UrlTemplates/UrlParser.fsi @@ -1,88 +1,88 @@ -namespace UrlTemplates.UrlParser - -open System -open System.Collections.Generic - -/// URL query values can be either a single string or a list of strings -/// This is used to represent the query parameters of a URL -[] -type QueryValue = - | String of value: string voption - | StringValues of values: string list - -/// A URL component can be a segment, a query or a hash -/// Segments are what is commonly known as the "path" of the URL -/// Query is the part of the URL that comes after the "?" and is usually -/// in the form of "key=value", although it can also be in the form of "key" -/// Hash is the part of the URL that comes after the "#" -[] -type UrlComponent = - | Segment of segment: string - | Query of query: Dictionary - | Hash of hash: string - -/// The result of parsing a URL -/// This is used to represent the different components of a URL -/// Since we're just parsing the string, all of the values are strings -type UrlInfo = - { - /// - /// The segments of the - /// - /// - /// /users/123 will have the segments - /// - /// [ "users"; "123" ] - /// - Segments: string list - - /// - /// The query parameters of the URL - /// - /// - /// /users?name&age<int>&optional will have the query parameters - /// - /// { - /// { "name", String "john" } - /// { "age", String "30" } - /// { "optional", ValueNone } - /// } - /// - Query: Dictionary - - /// - /// The hash of the URL - /// - /// - /// /users#123 will have the hash - /// - /// ValueSome "123" - /// - Hash: string voption - } - -[] -module UrlParser = - - /// - /// Takes an existing URI and returns a string representation of it - /// - /// The URI to convert to a string - /// - /// A string representation of the URI which can then be used to parse the URL - /// - /// - /// This is particularly useful to enable deep linking in an application. - /// You'd use this function to convert a URI to a string and then use the string - /// - val ofUri: uri: Uri -> string - - /// - /// Parses a URL and returns the different components of it - /// - /// The URL to parse - /// - /// A result that contains the different components of the URL if the URL is valid - /// or a string with the error message if the URL is invalid - /// - val ofString: url: string -> Result +namespace UrlTemplates.UrlParser + +open System +open System.Collections.Generic + +/// URL query values can be either a single string or a list of strings +/// This is used to represent the query parameters of a URL +[] +type QueryValue = + | String of value: string voption + | StringValues of values: string list + +/// A URL component can be a segment, a query or a hash +/// Segments are what is commonly known as the "path" of the URL +/// Query is the part of the URL that comes after the "?" and is usually +/// in the form of "key=value", although it can also be in the form of "key" +/// Hash is the part of the URL that comes after the "#" +[] +type UrlComponent = + | Segment of segment: string + | Query of query: Dictionary + | Hash of hash: string + +/// The result of parsing a URL +/// This is used to represent the different components of a URL +/// Since we're just parsing the string, all of the values are strings +type UrlInfo = + { + /// + /// The segments of the + /// + /// + /// /users/123 will have the segments + /// + /// [ "users"; "123" ] + /// + Segments: string list + + /// + /// The query parameters of the URL + /// + /// + /// /users?name&age<int>&optional will have the query parameters + /// + /// { + /// { "name", String "john" } + /// { "age", String "30" } + /// { "optional", ValueNone } + /// } + /// + Query: Dictionary + + /// + /// The hash of the URL + /// + /// + /// /users#123 will have the hash + /// + /// ValueSome "123" + /// + Hash: string voption + } + +[] +module UrlParser = + + /// + /// Takes an existing URI and returns a string representation of it + /// + /// The URI to convert to a string + /// + /// A string representation of the URI which can then be used to parse the URL + /// + /// + /// This is particularly useful to enable deep linking in an application. + /// You'd use this function to convert a URI to a string and then use the string + /// + val ofUri: uri: Uri -> string + + /// + /// Parses a URL and returns the different components of it + /// + /// The URL to parse + /// + /// A result that contains the different components of the URL if the URL is valid + /// or a string with the error message if the URL is invalid + /// + val ofString: url: string -> Result diff --git a/src/UrlTemplates/UrlTemplate.fs b/src/UrlTemplates/UrlTemplate.fs index 7f4aa23..72de04e 100644 --- a/src/UrlTemplates/UrlTemplate.fs +++ b/src/UrlTemplates/UrlTemplate.fs @@ -1,177 +1,183 @@ -namespace UrlTemplates.UrlTemplate - -open FParsec -open UrlTemplates.Parser -open FsToolkit.ErrorHandling - -[] -type TypedParam = - | String - | Int - | Float - | Bool - | Guid - | Long - | Decimal - -[] -type TemplateSegment = - | Plain of string - | ParamSegment of name: string * tipe: TypedParam - -[] -type QueryKey = - | Required of reqName: string * reqTipe: TypedParam - | Optional of name: string * tipe: TypedParam - -[] -type TemplateComponent = - | Segment of segment: TemplateSegment - | Query of query: QueryKey list - | Hash of hash: string voption - -[] -type UrlTemplate = { - Segments: TemplateSegment list - Query: QueryKey list - Hash: string voption -} - -[] -module UrlTemplate = - - module Primitives = - let intParam: Parser = - pstringCI "" <|> pstringCI "" >>= (fun _ -> preturn Int) - - let floatParam: Parser = - pstringCI "" <|> pstringCI "" >>= (fun _ -> preturn Float) - - let boolParam: Parser = - pstringCI "" <|> pstringCI "" >>= (fun _ -> preturn Bool) - - let guidParam: Parser = - pstringCI "" >>= (fun _ -> preturn Guid) - - let longParam: Parser = - pstringCI "" >>= (fun _ -> preturn Long) - - let decimalParam: Parser = - pstringCI "" >>= (fun _ -> preturn Decimal) - - let typed = - choice [ - Primitives.intParam - Primitives.floatParam - Primitives.boolParam - Primitives.guidParam - Primitives.longParam - Primitives.decimalParam - ] - - let required = pchar '!' - - // "/segment/" matches "segment" - let plainSegment = - manyChars(noneOf Common.SegmentDelimiters) - >>= (fun value -> Plain value |> preturn) - - // ":param/segment" matches "param" - // "segment/:param/segment" matches "param" and sets its type as an int - let paramSegment = - Common.ParamMarker >>. (manyChars asciiLetter) .>>. opt typed - >>= (fun (name, tipe) -> - ParamSegment(name, tipe |> Option.defaultValue String) |> preturn - ) - - // "?key&key2!" matches an optional "key" and a required "key2" - - let queryKey = - - (manyChars(asciiLetter <|> anyOf [ '-'; '_' ])) - .>>. opt typed - .>>. opt required - >>= (fun ((name, tipe), required) -> - match required with - | Some _ -> Required(name, tipe |> Option.defaultValue String) - | None -> Optional(name, tipe |> Option.defaultValue String) - |> preturn - ) - - let parse = - sepEndBy (paramSegment <|> plainSegment) Common.SegmentSeparator - .>>. opt(Common.QueryMarker >>. sepEndBy queryKey Common.QuerySeparator) - .>>. opt(Common.HashMarker >>. manyChars anyChar) - .>> eof - >>= (fun ((segments, query), hash) -> - let query = query |> Option.defaultValue [] - - { - Segments = - match segments with - | [ Plain ""; Plain "" ] -> [ Plain "" ] - | _ -> segments - Query = query - Hash = hash |> ValueOption.ofOption - } - |> preturn - ) - - let ofString (template: string) = - match run parse template with - | Success(result, _, _) -> Result.Ok result - | Failure(errorMsg, _, _) -> Result.Error errorMsg - - let toUrl - (template: string) - (routeParams: System.Collections.Generic.IReadOnlyDictionary) - = - result { - let! parsed = ofString template |> Result.mapError(fun x -> [ x ]) - - let! segments = - parsed.Segments - |> List.traverseResultA( - function - | Plain value -> Result.Ok value - | ParamSegment(name, _) -> - match routeParams.TryGetValue name with - | true, value -> Result.Ok(value.ToString()) - | false, _ -> - Result.Error $"Required route parameter %s{name} was not provided" - ) - |> Result.map(fun x -> x |> String.concat "/") - - let! query = - parsed.Query - |> List.traverseResultA( - function - | Required(name, _) -> - match routeParams.TryGetValue name with - | true, value -> Result.Ok($"{name}={value.ToString()}") - | false, _ -> - Result.Error $"Requred route parameter %s{name} was not provided" - | Optional(name, _) -> - match routeParams.TryGetValue name with - | true, value -> Result.Ok($"{name}={value.ToString()}") - | false, _ -> Result.Ok("") - ) - |> Result.map(fun x -> x |> String.concat "&") - - let hash = - match parsed.Hash with - | ValueSome value -> value - | ValueNone -> "" - - let sb = System.Text.StringBuilder() - - sb.Append segments |> ignore - - if not(System.String.IsNullOrWhiteSpace query) then - sb.Append("?").Append query |> ignore - - if not(System.String.IsNullOrWhiteSpace hash) then - sb.Append("#").Append hash |> ignore - - return sb.ToString() - } +namespace UrlTemplates.UrlTemplate + +open FParsec +open UrlTemplates.Parser +open FsToolkit.ErrorHandling + +[] +type TypedParam = + | String + | Int + | Float + | Bool + | Guid + | Long + | Decimal + +[] +type TemplateSegment = + | Plain of string + | ParamSegment of name: string * tipe: TypedParam + +[] +type QueryKey = + | Required of reqName: string * reqTipe: TypedParam + | Optional of name: string * tipe: TypedParam + +[] +type TemplateComponent = + | Segment of segment: TemplateSegment + | Query of query: QueryKey list + | Hash of hash: string voption + +[] +type UrlTemplate = { + Segments: TemplateSegment list + Query: QueryKey list + Hash: string voption +} + +[] +module UrlTemplate = + open System.Collections.Generic + + module Primitives = + let intParam: Parser = + pstringCI "" <|> pstringCI "" >>= (fun _ -> preturn Int) + + let floatParam: Parser = + pstringCI "" <|> pstringCI "" >>= (fun _ -> preturn Float) + + let boolParam: Parser = + pstringCI "" <|> pstringCI "" >>= (fun _ -> preturn Bool) + + let guidParam: Parser = + pstringCI "" >>= (fun _ -> preturn Guid) + + let longParam: Parser = + pstringCI "" >>= (fun _ -> preturn Long) + + let decimalParam: Parser = + pstringCI "" >>= (fun _ -> preturn Decimal) + + let typed = + choice [ + Primitives.intParam + Primitives.floatParam + Primitives.boolParam + Primitives.guidParam + Primitives.longParam + Primitives.decimalParam + ] + + let required = pchar '!' + + // "/segment/" matches "segment" + let plainSegment = + manyChars(noneOf Common.SegmentDelimiters) + >>= (fun value -> Plain value |> preturn) + + // ":param/segment" matches "param" + // "segment/:param/segment" matches "param" and sets its type as an int + let paramSegment = + Common.ParamMarker >>. (manyChars asciiLetter) .>>. opt typed + >>= (fun (name, tipe) -> + ParamSegment(name, tipe |> Option.defaultValue String) |> preturn + ) + + // "?key&key2!" matches an optional "key" and a required "key2" + + let queryKey = + + manyChars(asciiLetter <|> anyOf [ '-'; '_' ]) + .>>. opt typed + .>>. opt required + >>= (fun ((name, tipe), required) -> + match required with + | Some _ -> Required(name, tipe |> Option.defaultValue String) + | None -> Optional(name, tipe |> Option.defaultValue String) + |> preturn + ) + + let parse = + sepEndBy (paramSegment <|> plainSegment) Common.SegmentSeparator + .>>. opt(Common.QueryMarker >>. sepEndBy queryKey Common.QuerySeparator) + .>>. opt(Common.HashMarker >>. manyChars anyChar) + .>> eof + >>= (fun ((segments, query), hash) -> + let query = query |> Option.defaultValue [] + + { + Segments = + match segments with + | [ Plain ""; Plain "" ] -> [ Plain "" ] + | _ -> segments + Query = query + Hash = hash |> ValueOption.ofOption + } + |> preturn + ) + + let ofString (template: string) = + match run parse template with + | Success(result, _, _) -> Result.Ok result + | Failure(errorMsg, _, _) -> Result.Error errorMsg + + let toUrl (template: string) (routeParams: IReadOnlyDictionary) = result { + let routeParams = + routeParams + |> ValueOption.ofNull + |> ValueOption.defaultWith(fun _ -> new Dictionary()) + + let! parsed = ofString template |> Result.mapError(fun x -> [ x ]) + + let! segments = + parsed.Segments + |> List.traverseResultA( + function + | Plain value -> Result.Ok value + | ParamSegment(name, _) -> + match routeParams.TryGetValue name with + | true, value -> + match value.ToString() with + | null -> + Result.Error $"Required route parameter %s{name} was not provided" + | value -> Result.Ok value + | false, _ -> + Result.Error $"Required route parameter %s{name} was not provided" + ) + |> Result.map(fun x -> x |> String.concat "/") + + let! query = + parsed.Query + |> List.traverseResultA( + function + | Required(name, _) -> + match routeParams.TryGetValue name with + | true, value -> Result.Ok($"{name}={value.ToString()}") + | false, _ -> + Result.Error $"Required route parameter %s{name} was not provided" + | Optional(name, _) -> + match routeParams.TryGetValue name with + | true, value -> Result.Ok($"{name}={value.ToString()}") + | false, _ -> Result.Ok("") + ) + |> Result.map(fun x -> x |> String.concat "&") + + let hash = + match parsed.Hash with + | ValueSome value -> value + | ValueNone -> "" + + let sb = System.Text.StringBuilder() + + sb.Append segments |> ignore + + if not(System.String.IsNullOrWhiteSpace query) then + sb.Append("?").Append query |> ignore + + if not(System.String.IsNullOrWhiteSpace hash) then + sb.Append("#").Append hash |> ignore + + return sb.ToString() + } diff --git a/src/UrlTemplates/UrlTemplate.fsi b/src/UrlTemplates/UrlTemplate.fsi index 37b81fb..92ce3cf 100644 --- a/src/UrlTemplates/UrlTemplate.fsi +++ b/src/UrlTemplates/UrlTemplate.fsi @@ -1,99 +1,99 @@ -namespace UrlTemplates.UrlTemplate - -/// A few well known types that can be used as as the types for the parameters of a URL. -[] -type TypedParam = - | String - | Int - | Float - | Bool - | Guid - | Long - | Decimal - -/// A discriminated union that represents the different types of segments that a URL can have -[] -type TemplateSegment = - /// - /// A segment of the URL that is a constant string - /// - /// - /// /users/disable will have the segments - /// - /// [ Plain "users"; Plain "disable" ] - /// - | Plain of string - /// - /// A segment of the URL that is a parameter - /// - /// - /// /users/:id will have the segments - /// - /// [ Plain "users"; ParamSegment ("id", String) ] - /// - | ParamSegment of name: string * tipe: TypedParam - -/// A discriminated union that represents the different types of query parameters that a URL can have -[] -type QueryKey = - /// - /// A query parameter that is required in the URL - /// - /// - /// /?name! means that the URL must have the query parameter "name" - /// Required("name", String) - /// - | Required of reqName: string * reqTipe: TypedParam - /// - /// A query parameter that is optional in the URL - /// - /// - /// /?name means that the URL can have the query parameter "name" - /// Optional("name", String) - /// - | Optional of name: string * tipe: TypedParam - -/// A discriminated union that represents the different types of components that a URL can have -[] -type TemplateComponent = - | Segment of segment: TemplateSegment - | Query of query: QueryKey list - | Hash of hash: string voption - -/// -/// This object is the representation of an URL template including -/// the segments, query parameters and hash and its corresponding types -/// in the case of the segments and query parameters -/// -[] -type UrlTemplate = - { Segments: TemplateSegment list - Query: QueryKey list - Hash: string voption } - -[] -module UrlTemplate = - - /// - /// Parses a string into a URL template - /// - /// The string to parse into a URL template - /// - /// A URL template if the string is a valid URL template - /// or a string with the error message if the string is not a valid URL template - /// - val ofString: template: string -> Result - - /// - /// Converts a URL template into a well formed and matching URL - /// - /// The URL template to convert into a URL - /// The parameters to use to convert the URL template into a URL - /// - /// A string with the URL if the parameters are valid - /// or a list of string with the error messages if the parameters are not valid - /// - val toUrl: - template: string -> - routeParams: System.Collections.Generic.IReadOnlyDictionary -> - Result +namespace UrlTemplates.UrlTemplate + +/// A few well known types that can be used as as the types for the parameters of a URL. +[] +type TypedParam = + | String + | Int + | Float + | Bool + | Guid + | Long + | Decimal + +/// A discriminated union that represents the different types of segments that a URL can have +[] +type TemplateSegment = + /// + /// A segment of the URL that is a constant string + /// + /// + /// /users/disable will have the segments + /// + /// [ Plain "users"; Plain "disable" ] + /// + | Plain of string + /// + /// A segment of the URL that is a parameter + /// + /// + /// /users/:id will have the segments + /// + /// [ Plain "users"; ParamSegment ("id", String) ] + /// + | ParamSegment of name: string * tipe: TypedParam + +/// A discriminated union that represents the different types of query parameters that a URL can have +[] +type QueryKey = + /// + /// A query parameter that is required in the URL + /// + /// + /// /?name! means that the URL must have the query parameter "name" + /// Required("name", String) + /// + | Required of reqName: string * reqTipe: TypedParam + /// + /// A query parameter that is optional in the URL + /// + /// + /// /?name means that the URL can have the query parameter "name" + /// Optional("name", String) + /// + | Optional of name: string * tipe: TypedParam + +/// A discriminated union that represents the different types of components that a URL can have +[] +type TemplateComponent = + | Segment of segment: TemplateSegment + | Query of query: QueryKey list + | Hash of hash: string voption + +/// +/// This object is the representation of an URL template including +/// the segments, query parameters and hash and its corresponding types +/// in the case of the segments and query parameters +/// +[] +type UrlTemplate = + { Segments: TemplateSegment list + Query: QueryKey list + Hash: string voption } + +[] +module UrlTemplate = + + /// + /// Parses a string into a URL template + /// + /// The string to parse into a URL template + /// + /// A URL template if the string is a valid URL template + /// or a string with the error message if the string is not a valid URL template + /// + val ofString: template: string -> Result + + /// + /// Converts a URL template into a well formed and matching URL + /// + /// The URL template to convert into a URL + /// The parameters to use to convert the URL template into a URL + /// + /// A string with the URL if the parameters are valid + /// or a list of string with the error messages if the parameters are not valid + /// + val toUrl: + template: string -> + routeParams: System.Collections.Generic.IReadOnlyDictionary -> + Result diff --git a/src/UrlTemplates/UrlTemplates.fsproj b/src/UrlTemplates/UrlTemplates.fsproj index 07f6a8e..50ce673 100644 --- a/src/UrlTemplates/UrlTemplates.fsproj +++ b/src/UrlTemplates/UrlTemplates.fsproj @@ -1,8 +1,7 @@  - net8.0;net9.0 - true + net10.0;net8.0 @@ -17,8 +16,13 @@ - - + + + + + + +