├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── docs.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── content │ ├── .vitepress │ │ ├── config.js │ │ └── theme │ │ │ ├── custom.css │ │ │ └── index.js │ ├── advanced │ │ ├── load-balancing.md │ │ └── request-queuing.md │ ├── examples │ │ └── apps.md │ ├── features │ │ ├── actions.md │ │ ├── authorization.md │ │ ├── binding.md │ │ ├── components.md │ │ ├── cookies.md │ │ ├── errors-handling.md │ │ ├── events.md │ │ ├── form-validation.md │ │ ├── js.md │ │ ├── long-polling.md │ │ ├── navigation.md │ │ ├── parameters.md │ │ ├── ui-utils.md │ │ └── xsrf-token.md │ ├── home-layout-wrapper.css │ ├── index.md │ ├── introduction │ │ ├── comparisons.md │ │ ├── getting-started.md │ │ ├── motivation.md │ │ └── overview.md │ ├── markdown-examples.md │ ├── public │ │ └── logo.png │ └── utilities │ │ └── hydro-views.md ├── package-lock.json └── package.json ├── publish.cmd ├── shared ├── logo.png ├── logo.svg ├── logo_80.png ├── logo_95.png ├── logo_s.svg └── logo_s3.svg └── src ├── Cache.cs ├── CacheKey.cs ├── CacheLifetime.cs ├── ComponentResult.cs ├── ComponentResults.cs ├── Configuration ├── ApplicationBuilderExtensions.cs ├── HydroOptions.cs └── ServiceCollectionExtensions.cs ├── DummyView.cs ├── ExpressionExtensions.cs ├── HtmlTemplate.cs ├── Hydro.csproj ├── Hydro.sln ├── HydroBind.cs ├── HydroClientActions.cs ├── HydroComponent.cs ├── HydroComponentEvent.cs ├── HydroComponentsExtensions.cs ├── HydroConsts.cs ├── HydroEmptyResult.cs ├── HydroEventPayload.cs ├── HydroEventSubscription.cs ├── HydroHtmlExtensions.cs ├── HydroHttpContextExtensions.cs ├── HydroHttpResponseExtensions.cs ├── HydroModelMetadataProvider.cs ├── HydroPageExtensions.cs ├── HydroPoll.cs ├── HydroValueMapper.cs ├── HydroView.cs ├── ICompositeValidationResult.cs ├── IHydroAuthorizationFilter.cs ├── JsonSettings.cs ├── KeyBehavior.cs ├── Param.cs ├── PersistentState.cs ├── PollAttribute.cs ├── PropertyExtractor.cs ├── PropertyInjector.cs ├── Scope.cs ├── Scripts ├── AlpineJs │ ├── alpinejs-LICENSE │ └── alpinejs-combined.min.js └── hydro.js ├── ScriptsFileProvider.cs ├── SelectValue.cs ├── Services └── CookieStorage.cs ├── SkipOutputAttribute.cs ├── SlotContext.cs ├── SlotTagHelper.cs ├── TagHelperRenderer.cs ├── TagHelpers ├── AntiforgeryMetaTagHelper.cs ├── HydroBindShorthandTagHelper.cs ├── HydroBindTagHelper.cs ├── HydroComponentTagHelper.cs ├── HydroDispatchTagHelper.cs ├── HydroLinkTagHelper.cs └── HydroOnTagHelper.cs ├── TransientAttribute.cs ├── UnhandledHydroError.cs ├── Utils ├── Base64Extensions.cs ├── Int32Converter.cs ├── JetBrains.Annotations.cs └── PropertyPath.cs ├── Validation ├── ValidateCollectionAttribute.cs └── ValidateObjectAttribute.cs ├── ViewBag.cs └── ViewComponentExtensions.cs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [kjeske] 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: 8.0.x 20 | - name: Restore dependencies 21 | run: dotnet restore 22 | working-directory: src 23 | - name: Build 24 | run: dotnet build --no-restore -c Release 25 | working-directory: src 26 | - name: Pack 27 | run: dotnet pack -c Release -o ../nupkgs --no-build 28 | working-directory: src 29 | - name: Upload artifact 30 | uses: actions/upload-artifact@v4.6.2 31 | with: 32 | path: ./nupkgs/* 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'docs/**' 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | concurrency: 17 | group: pages 18 | cancel-in-progress: false 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | with: 27 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 28 | - name: Setup Node 29 | uses: actions/setup-node@v3 30 | with: 31 | node-version: 18 32 | cache: npm 33 | cache-dependency-path: docs/package-lock.json 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v3 36 | - name: Install dependencies 37 | run: npm ci 38 | working-directory: docs 39 | - name: Build with VitePress 40 | working-directory: docs 41 | run: | 42 | npm run docs:build 43 | touch content/.vitepress/dist/.nojekyll 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@3.0.1 46 | with: 47 | path: docs/content/.vitepress/dist 48 | 49 | deploy: 50 | environment: 51 | name: github-pages 52 | url: ${{ steps.deployment.outputs.page_url }} 53 | needs: build 54 | runs-on: ubuntu-latest 55 | name: Deploy 56 | steps: 57 | - name: Deploy to GitHub Pages 58 | id: deployment 59 | uses: actions/deploy-pages@v4.0.5 60 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NuGet 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: 8.0.x 20 | - name: Restore dependencies 21 | run: dotnet restore 22 | working-directory: src 23 | - name: Build 24 | run: dotnet build --no-restore -c Release 25 | working-directory: src 26 | - name: Pack 27 | run: dotnet pack -c Release -o ../nupkgs --no-build 28 | working-directory: src 29 | - name: Publish 30 | run: dotnet nuget push ./nupkgs/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --skip-duplicate -s https://api.nuget.org/v3/index.json 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | appsettings.Development.json 2 | 3 | # Azure Functions localsettings file 4 | local.settings.json 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # DNX 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | 50 | *_i.c 51 | *_p.c 52 | *_i.h 53 | *.ilk 54 | *.meta 55 | *.obj 56 | *.pch 57 | *.pdb 58 | *.pgc 59 | *.pgd 60 | *.rsp 61 | *.sbr 62 | *.tlb 63 | *.tli 64 | *.tlh 65 | *.tmp 66 | *.tmp_proj 67 | *.log 68 | *.vspscc 69 | *.vssscc 70 | .builds 71 | *.pidb 72 | *.svclog 73 | *.scc 74 | 75 | # Chutzpah Test files 76 | _Chutzpah* 77 | 78 | # Visual C++ cache files 79 | ipch/ 80 | *.aps 81 | *.ncb 82 | *.opendb 83 | *.opensdf 84 | *.sdf 85 | *.cachefile 86 | *.VC.db 87 | *.VC.VC.opendb 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | *.sap 94 | 95 | # TFS 2012 Local Workspace 96 | $tf/ 97 | 98 | # Guidance Automation Toolkit 99 | *.gpState 100 | 101 | # ReSharper is a .NET coding add-in 102 | _ReSharper*/ 103 | *.[Rr]e[Ss]harper 104 | *.DotSettings.user 105 | 106 | # JustCode is a .NET coding add-in 107 | .JustCode 108 | 109 | # TeamCity is a build add-in 110 | _TeamCity* 111 | 112 | # DotCover is a Code Coverage Tool 113 | *.dotCover 114 | 115 | # NCrunch 116 | _NCrunch_* 117 | .*crunch*.local.xml 118 | nCrunchTemp_* 119 | 120 | # MightyMoose 121 | *.mm.* 122 | AutoTest.Net/ 123 | 124 | # Web workbench (sass) 125 | .sass-cache/ 126 | 127 | # Installshield output folder 128 | [Ee]xpress/ 129 | 130 | # DocProject is a documentation generator add-in 131 | DocProject/buildhelp/ 132 | DocProject/Help/*.HxT 133 | DocProject/Help/*.HxC 134 | DocProject/Help/*.hhc 135 | DocProject/Help/*.hhk 136 | DocProject/Help/*.hhp 137 | DocProject/Help/Html2 138 | DocProject/Help/html 139 | 140 | # Click-Once directory 141 | publish/ 142 | 143 | # Publish Web Output 144 | *.[Pp]ublish.xml 145 | *.azurePubxml 146 | # TODO: Comment the next line if you want to checkin your web deploy settings 147 | # but database connection strings (with potential passwords) will be unencrypted 148 | #*.pubxml 149 | *.publishproj 150 | 151 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 152 | # checkin your Azure Web App publish settings, but sensitive information contained 153 | # in these scripts will be unencrypted 154 | PublishScripts/ 155 | 156 | # NuGet Packages 157 | *.nupkg 158 | # The packages folder can be ignored because of Package Restore 159 | **/packages/* 160 | # except build/, which is used as an MSBuild target. 161 | !**/packages/build/ 162 | # Uncomment if necessary however generally it will be regenerated when needed 163 | #!**/packages/repositories.config 164 | # NuGet v3's project.json files produces more ignoreable files 165 | *.nuget.props 166 | *.nuget.targets 167 | 168 | # Microsoft Azure Build Output 169 | csx/ 170 | *.build.csdef 171 | 172 | # Microsoft Azure Emulator 173 | ecf/ 174 | rcf/ 175 | 176 | # Windows Store app package directories and files 177 | AppPackages/ 178 | BundleArtifacts/ 179 | Package.StoreAssociation.xml 180 | _pkginfo.txt 181 | 182 | # Visual Studio cache files 183 | # files ending in .cache can be ignored 184 | *.[Cc]ache 185 | # but keep track of directories ending in .cache 186 | !*.[Cc]ache/ 187 | 188 | # Others 189 | ClientBin/ 190 | ~$* 191 | *~ 192 | *.dbmdl 193 | *.dbproj.schemaview 194 | *.jfm 195 | *.pfx 196 | *.publishsettings 197 | node_modules/ 198 | orleans.codegen.cs 199 | 200 | # Since there are multiple workflows, uncomment next line to ignore bower_components 201 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 202 | #bower_components/ 203 | 204 | # RIA/Silverlight projects 205 | Generated_Code/ 206 | 207 | # Backup & report files from converting an old project file 208 | # to a newer Visual Studio version. Backup files are not needed, 209 | # because we have git ;-) 210 | _UpgradeReport_Files/ 211 | Backup*/ 212 | UpgradeLog*.XML 213 | UpgradeLog*.htm 214 | 215 | # SQL Server files 216 | *.mdf 217 | *.ldf 218 | 219 | # Business Intelligence projects 220 | *.rdl.data 221 | *.bim.layout 222 | *.bim_*.settings 223 | 224 | # Microsoft Fakes 225 | FakesAssemblies/ 226 | 227 | # GhostDoc plugin setting file 228 | *.GhostDoc.xml 229 | 230 | # Node.js Tools for Visual Studio 231 | .ntvs_analysis.dat 232 | 233 | # Visual Studio 6 build log 234 | *.plg 235 | 236 | # Visual Studio 6 workspace options file 237 | *.opt 238 | 239 | # Visual Studio LightSwitch build output 240 | **/*.HTMLClient/GeneratedArtifacts 241 | **/*.DesktopClient/GeneratedArtifacts 242 | **/*.DesktopClient/ModelManifest.xml 243 | **/*.Server/GeneratedArtifacts 244 | **/*.Server/ModelManifest.xml 245 | _Pvt_Extensions 246 | 247 | # Paket dependency manager 248 | .paket/paket.exe 249 | paket-files/ 250 | 251 | # FAKE - F# Make 252 | .fake/ 253 | 254 | # JetBrains Rider 255 | .idea/ 256 | *.sln.iml 257 | 258 | # CodeRush 259 | .cr/ 260 | 261 | # Python Tools for Visual Studio (PTVS) 262 | __pycache__/ 263 | *.pyc 264 | 265 | 266 | # misc 267 | .DS_Store 268 | .env.local 269 | .env.development.local 270 | .env.test.local 271 | .env.production.local 272 | 273 | npm-debug.log* 274 | yarn-debug.log* 275 | yarn-error.log* 276 | 277 | **/ClientApp/build 278 | **/Logs/*.txt 279 | **/Properties/PublishProfiles/ 280 | 281 | **/appsettings.Development.json 282 | **/appsettings.Staging.json 283 | **/local.settings.json 284 | .local-chromium 285 | /.vscode 286 | **/.vitepress/cache/ 287 | **/.vitepress/dist/ 288 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Krzysztof Jeske 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Hydro](./shared/logo_s3.svg) 2 | 3 | --- 4 | Bring stateful and reactive components to ASP.NET Core without writing JavaScript 5 | 6 | --- 7 | 8 | Hydro is an extension to ASP.NET Core MVC and Razor Pages. It extends View Components to make them reactive and stateful with ability to communicate with each other without page reloads. As a result, you can create powerful components and make your application to feel like SPA with zero or minimal amount of the JavaScript code (depending on the needs) and without separate front-end build step. It can be used in new or existing ASP.NET Core applications. 9 | 10 | Hydro utilizes the following technologies to make it all work: 11 | 12 | - **Razor views (\*.cshtml)** 13 | Razor views form the backbone of Hydro's UI generation. They allow for a familiar, server-side rendering strategy that has been a foundation of .NET web development for many years. These *.cshtml files enable a seamless mix of HTML and C# code, allowing for robust and dynamic webpage generation. 14 | 15 | 16 | - **AJAX** 17 | AJAX calls are used to communicate between the client and the server, specifically to send the application state to the server, receive updates and responses back, and then store this state to be used in subsequent requests. This ensures that each request has the most up-to-date context and information. 18 | 19 | 20 | - **Alpine.js** 21 | Alpine.js stands as a base for requests execution and DOM swapping. But beyond that, Alpine.js also empowers users by providing a framework for adding rich, client-side interactivity to the standard HTML. So, not only does it serve Hydro's internal operations, but it also provides an expansion point for users to enhance their web applications with powerful, interactive experiences. 22 | 23 | ## Documentation 24 | 25 | - [Hydro documentation](https://usehydro.dev) 26 | 27 | ## Toolkit 28 | 29 | - [Hydro toolkit](https://toolkit.usehydro.dev/) 30 | 31 | ## Samples 32 | 33 | - [Simple sales application using Hydro](https://github.com/hydrostack/hydro-sales) 34 | - [Simple to-do application using Hydro](https://github.com/hydrostack/hydro-todo) 35 | 36 | ## Installation 37 | 38 | In ASP.NET Core Razor Pages / MVC project 6.0+ install Hydro package: 39 | ```console 40 | dotnet add package Hydro 41 | ``` 42 | 43 | If you don't have application yet, you can create it first: 44 | 45 | ```console 46 | dotnet new webapp -o MyApp 47 | cd MyApp 48 | ``` 49 | 50 | In your application's startup code (either `Program.cs` or `Startup.cs`): 51 | 52 | ```c# 53 | builder.Services.AddHydro(); 54 | 55 | ... 56 | 57 | app.UseHydro(builder.Environment); 58 | ``` 59 | 60 | In `_ViewImports.cshtml` add: 61 | ```razor 62 | @addTagHelper *, {Your project assembly name} 63 | @addTagHelper *, Hydro 64 | ``` 65 | 66 | In layout's `head` tag: 67 | ```html 68 | 69 | 70 | 71 | ``` 72 | 73 | ## Quick start 74 | To create Hydro component, go to your components folder, for example in case of Razor Pages: `~/Pages/Components/`, and create these files: 75 | 76 | ```razor 77 | 78 | 79 | @model Counter 80 | 81 |
82 | Count: @Model.Count 83 | 86 |
87 | ``` 88 | ```c# 89 | // Counter.cs 90 | 91 | public class Counter : HydroComponent 92 | { 93 | public int Count { get; set; } 94 | 95 | public void Add() 96 | { 97 | Count++; 98 | } 99 | } 100 | ``` 101 | 102 | ### Usage 103 | 104 | To use your new component, you can render it in your Razor Page (e.g. `Index.cshtml`) in two ways: 105 | 106 | by calling a custom tag: 107 | ```razor 108 | ... 109 | 110 | ... 111 | ``` 112 | 113 | by calling a generic tag helper: 114 | 115 | ```razor 116 | ... 117 | 118 | ... 119 | ``` 120 | 121 | or by calling an extension method: 122 | ```razor 123 | ... 124 | @await Html.Hydro("Counter") 125 | ... 126 | ``` 127 | 128 | And voilà! You can test your component by clicking on the `Add` button. 129 | 130 | ## External libraries 131 | 132 | Hydro repository contains [Alpine.js](https://github.com/alpinejs/alpine) libraries MIT licensed. 133 | 134 | ## License 135 | 136 | Hydro is Copyright © Krzysztof Jeske and other contributors under the [MIT license](https://raw.githubusercontent.com/hydrostack/hydro/main/LICENSE) 137 | -------------------------------------------------------------------------------- /docs/content/.vitepress/config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "Hydro", 6 | description: "Stateful components for Razor Pages", 7 | themeConfig: { 8 | // https://vitepress.dev/reference/default-theme-config 9 | nav: [ 10 | { text: 'Introduction', link: '/introduction/getting-started' }, 11 | { text: 'Guide', link: '/features/components' }, 12 | { text: 'Toolkit', link: 'https://toolkit.usehydro.dev' }, 13 | { text: 'Sponsor', link: 'https://github.com/sponsors/kjeske' } 14 | ], 15 | sidebar: [ 16 | { 17 | text: 'Introduction', 18 | items: [ 19 | { text: 'Overview', link: '/introduction/overview' }, 20 | { text: 'Motivation', link: '/introduction/motivation' }, 21 | { text: 'Getting started', link: '/introduction/getting-started' }, 22 | { text: 'Comparisons', link: '/introduction/comparisons' } 23 | ] 24 | }, 25 | { 26 | text: 'Features', 27 | items: [ 28 | { text: 'Components', link: '/features/components' }, 29 | { text: 'Parameters', link: '/features/parameters' }, 30 | { text: 'Binding', link: '/features/binding' }, 31 | { text: 'Actions', link: '/features/actions' }, 32 | { text: 'Events', link: '/features/events' }, 33 | { text: 'Navigation', link: '/features/navigation' }, 34 | { text: 'Authorization', link: '/features/authorization' }, 35 | { text: 'Form validation', link: '/features/form-validation' }, 36 | { text: 'Cookies', link: '/features/cookies' }, 37 | { text: 'Long polling', link: '/features/long-polling' }, 38 | { text: 'Errors handling', link: '/features/errors-handling' }, 39 | { text: 'Anti-forgery token', link: '/features/xsrf-token' }, 40 | { text: 'User interface utilities', link: '/features/ui-utils' }, 41 | { text: 'Using JavaScript', link: '/features/js' }, 42 | ] 43 | }, 44 | { 45 | text: 'Advanced', 46 | items: [ 47 | { text: 'Request queuing', link: '/advanced/request-queuing' }, 48 | // { text: 'Load balancing', link: '/advanced/load-balancing' }, 49 | ] 50 | }, 51 | { 52 | text: 'Utilities', 53 | items: [ 54 | { text: 'Hydro views', link: '/utilities/hydro-views' }, 55 | ] 56 | }, 57 | { 58 | text: 'Examples', 59 | items: [ 60 | { text: 'Apps', link: '/examples/apps' }, 61 | ] 62 | } 63 | ], 64 | 65 | socialLinks: [ 66 | { icon: 'x', link: 'https://x.com/usehydro' }, 67 | { icon: 'github', link: 'https://github.com/hydrostack/hydro/' }, 68 | ] 69 | } 70 | }) 71 | -------------------------------------------------------------------------------- /docs/content/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand-1: #4586d0; 3 | --vp-c-brand-2: #9948d8; 4 | --vp-c-brand-3: #7b34b2; 5 | 6 | --vp-home-hero-name-color: transparent; 7 | --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #850abf 30%, #ff4141); 8 | 9 | --vp-home-hero-image-background-image: linear-gradient(-45deg, #ff428e 50%, #fa7df9 50%); 10 | --vp-home-hero-image-filter: blur(40px) opacity(63%); 11 | } 12 | -------------------------------------------------------------------------------- /docs/content/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme' 2 | import './custom.css' 3 | 4 | export default DefaultTheme -------------------------------------------------------------------------------- /docs/content/advanced/load-balancing.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Load balancing the application 6 | 7 | Very often applications need to encrypt the data exposed to the client, for example: 8 | - authentication cookies 9 | - antiforgery token 10 | - Hydro is encrypting the state of the components 11 | 12 | For that purpose ASP.NET Core apps use tools that are a part of [Data Protection](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/introduction), which provides a cryptographic API to protect data. 13 | It's using a **cryptographic key** for the encryption. 14 | 15 | ### Problem 16 | 17 | On hosting with only one node such a key is stored locally in `DataProtection-Keys` directory and everything works without additional configuration. 18 | When using load balancing, so when having multiple nodes with the same application, we have to **provide the same cryptographic key for all the nodes**, 19 | so the encrypted data looks the same no matter which node generated it. 20 | 21 | ### Solution 22 | 23 | Use a shared storage to keep the key in one place and available for all the nodes. There are several [kinds of storages](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/key-storage-providers) you can use: 24 | - Database 25 | - File system pointing to a network share 26 | - Azure Storage 27 | - Redis 28 | 29 | Example of configuration using Entity Framework Core: 30 | 31 | ```c# 32 | services.AddDataProtection() 33 | .PersistKeysToDbContext(); 34 | ``` -------------------------------------------------------------------------------- /docs/content/advanced/request-queuing.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Request queuing 6 | 7 | With AJAX operations it's possible to run into a situation known as a "race condition" of requests. For example, suppose we have two AJAX calls, Call A and Call B, initiated from the client side to update the same piece of data on the server. The issue arises when Call B is initiated before Call A has received its response from the server, resulting in Call B working with stale or outdated data. 8 | 9 | Hydro addresses this challenge elegantly by ensuring that all requests made from the UI are chained and executed one after another, not in parallel. This essentially creates a queue of requests. Once a request is completed and the response is received, the next request in the queue is initiated. 10 | 11 | This strategy ensures that each request has the most recent data from the previous request response, thereby effectively eliminating the potential for race conditions. 12 | 13 | This also ensures consistency of the state on the client side, as it always reflects the most recent state of the server, even in highly interactive and dynamic UIs. 14 | 15 | Therefore, you can confidently make multiple asynchronous calls to the server without worrying about potential race conditions. Hydro handles the complexity for you, allowing you to focus on building your application logic. -------------------------------------------------------------------------------- /docs/content/examples/apps.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Examples of applications 6 | 7 | List of sample applications that show how to work with Hydro. 8 | 9 | - [Hydro Sales](https://github.com/hydrostack/hydro-sales) 10 | - [Hydro to-do list](https://github.com/hydrostack/hydro-todo) -------------------------------------------------------------------------------- /docs/content/features/actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Actions 6 | 7 | Actions are methods defined in the component class. They allow you to react to user interactions. 8 | 9 | Let's take a look at the following component: 10 | 11 | ```c# 12 | // Counter.cshtml.cs 13 | 14 | public class Counter : HydroComponent 15 | { 16 | public int Count { get; set; } 17 | 18 | public void Add() 19 | { 20 | Count++; 21 | } 22 | } 23 | ``` 24 | 25 | A browser event of an element can be attached to an action method by using the `on` tag helper: 26 | 27 | ```razor 28 | 29 | 30 | @model Counter 31 |
32 | Count: @Model.Count 33 | 36 |
37 | ``` 38 | 39 | ```razor 40 | 41 | 42 | @model Counter 43 |
44 | Count: @Model.Count 45 | 48 |
49 | ``` 50 | 51 | Alternatively, you can use the `hydro-on` tag helper: 52 | 53 | ```razor 54 | 57 | ``` 58 | 59 | The attribute `on` can be defined as: 60 | 61 | > on:**event**="**expression**" 62 | 63 | where: 64 | - **event**: a definition of the event handler that is compatible with Alpine.js's [x-on directive](https://alpinejs.dev/directives/on) 65 | - **expression**: C# lambda expression that calls the the callback method 66 | 67 | Examples: 68 | 69 | ```razor 70 | 106 | 107 | ``` 108 | 109 | ## Results 110 | 111 | Hydro provides multiple component results that can be returned from an action: 112 | 113 | - `ComponentResults.Challenge` 114 | 115 | Calls `HttpContext.ChallangeAsync` and handles further redirections 116 | 117 | - `ComponentResults.SignIn` 118 | 119 | Calls `HttpContext.SignInAsync` and handles further redirections 120 | 121 | - `ComponentResults.SignOut` 122 | 123 | Calls `HttpContext.SignOutAsync` and handles further redirections 124 | 125 | - `ComponentResults.File` 126 | 127 | Returns a file from the server 128 | 129 | 130 | 131 | Examples: 132 | 133 | ```c# 134 | // ShowInvoice.cshtml.cs 135 | 136 | public class ShowInvoice : HydroComponent 137 | { 138 | public IComponentResult Download() 139 | { 140 | return ComponentResults.File("./storage/file.pdf", MediaTypeNames.Application.Pdf); 141 | } 142 | } 143 | ``` 144 | 145 | ```c# 146 | // Profile.cshtml.cs 147 | 148 | public class Profile : HydroComponent 149 | { 150 | public IComponentResult LoginWithGitHub() 151 | { 152 | var properties = new AuthenticationProperties 153 | { 154 | RedirectUri = RedirectUri, 155 | IsPersistent = true 156 | }; 157 | 158 | return ComponentResults.Challenge(properties, [GitHubAuthenticationDefaults.AuthenticationScheme]); 159 | } 160 | } 161 | ``` 162 | 163 | ## JavaScript expression as a parameter 164 | 165 | In some cases, like integrating with JavaScript libraries like maps, rich-text editors, etc. it might be useful to 166 | call a Hydro action with parameters evaluated on client side via JavaScript expression. You can use then `Param.JS(string value)` method, where: 167 | - `T`: type of the parameter 168 | - `value`: JavaScript expression to evaluate 169 | 170 | If your parameter type is a `string`, you can use a shorter version: 171 | 172 | `Param.JS(string value)` 173 | 174 | Example: 175 | 176 | ```c# 177 | // Content.cshtml.cs 178 | 179 | public class Content : HydroComponent 180 | { 181 | public void Update(string value) 182 | { 183 | // ... 184 | } 185 | } 186 | ``` 187 | 188 | ```razor 189 | 190 | 191 | @model Content 192 |
193 | 194 | 197 |
198 | ``` 199 | 200 | After clicking the button from the code above, Hydro will execute the expression 201 | `window.myInput.value` on the client side, and pass it as a `value` parameter to the `Update` action. 202 | 203 | > NOTE: In case of using widely this feature in your component, you can add: 204 | > 205 | > ```@using static Hydro.Param``` and call `JS` without `Param.` prefix. 206 | 207 | ## Asynchronous actions 208 | 209 | If you want to use asynchronous operations in your actions, just change the signature of the method as in this example: 210 | ```c# 211 | public async Task Add() 212 | { 213 | await database.Add(); 214 | } 215 | 216 | ``` -------------------------------------------------------------------------------- /docs/content/features/authorization.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Authorization 6 | 7 | Hydro provides `IHydroAuthorizationFilter` interface that can be used for creating custom 8 | authorization attributes for Hydro components. 9 | 10 | Attribute example: 11 | ```c# 12 | public sealed class CustomComponentAuthorizeAttribute : Attribute, IHydroAuthorizationFilter 13 | { 14 | public Task AuthorizeAsync(HttpContext httpContext, object component) 15 | { 16 | var isAuthorized = httpContext.User.Identity?.IsAuthenticated ?? false; 17 | return Task.FromResult(isAuthorized); 18 | } 19 | } 20 | ``` 21 | 22 | Usage: 23 | ```c# 24 | [CustomComponentAuthorize] 25 | public class ProductList : HydroComponent 26 | { 27 | } 28 | ``` 29 | 30 | If `AuthorizeAsync` returns `false`, the component won't be rendered. 31 | 32 | ## Using component state for authorization process 33 | 34 | It might happen that authorization will be dependent on state of your component. One of the 35 | ways to solve it is to create an interface representing the state you need for authorization. 36 | 37 | Interface: 38 | ```c# 39 | public interface IWorkspaceComponent 40 | { 41 | string WorkspaceId { get; } 42 | } 43 | ``` 44 | 45 | Authorization attribute: 46 | ```c# 47 | public sealed class WorkspaceComponentAuthorizeAttribute : Attribute, IHydroAuthorizationFilter 48 | { 49 | public async Task AuthorizeAsync(HttpContext httpContext, object component) 50 | { 51 | var workspaceComponent = (IWorkspaceComponent)component; 52 | var authorizationService = httpContext.RequestServices.GetRequiredService(); 53 | return authorizationService.IsWorkspaceAuthorized(workspaceComponent.WorkspaceId); 54 | } 55 | } 56 | ``` 57 | 58 | ```c# 59 | [WorkspaceComponentAuthorize] 60 | public class ProductList : HydroComponent, IWorkspaceComponent 61 | { 62 | public string WorkspaceId { get; set; } 63 | } 64 | ``` -------------------------------------------------------------------------------- /docs/content/features/binding.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Binding 6 | 7 | You can bind your component's properties to any input/select/textarea element by using `bind` or `hydro-bind`. It will synchronize the client value with server value on the chosen event (`change` as default). 8 | 9 | Example: 10 | 11 | ```csharp 12 | // NameForm.cshtml.cs 13 | 14 | public class NameForm : HydroComponent 15 | { 16 | public string FirstName { get; set; } 17 | public string LastName { get; set; } 18 | } 19 | ``` 20 | 21 | ```razor 22 | 23 | 24 | @model NameForm 25 | 26 |
27 | 28 | 29 | Full name: @Model.FirstName @Model.LastName 30 |
31 | ``` 32 | 33 | Alternatively, you can use the `hydro-bind` attribute: 34 | 35 | ```razor 36 | 37 | ``` 38 | 39 | The `asp-for` is a built-in ASP.NET Core tag helper that generates the `name`, `id` and `value` attributes for the input element. It's not required and you can provide these attributes manually: 40 | 41 | ```razor 42 | 43 | 44 | @model NameForm 45 | 46 |
47 | 48 | 49 | Full name: @Model.FirstName @Model.LastName 50 |
51 | ``` 52 | 53 | ## Trigger event 54 | 55 | The default event used for binding is `change`. To choose another event, you can specify it: 56 | 57 | ```razor 58 | 59 | ``` 60 | 61 | ## Debouncing 62 | 63 | To debounce the bind event use the `.debounce` attribute expression: 64 | ```razor 65 | 66 | ``` 67 | 68 | ## Handling `bind` event in a component 69 | 70 | In order to inject custom logic after `bind` is executed, override the `Bind` method in your Hydro component: 71 | 72 | ```c# 73 | public string Name { get; set; } 74 | 75 | public override void Bind(PropertyPath property, object value) 76 | { 77 | if (property.Name == nameof(Name)) 78 | { 79 | var newValue = (string)value; 80 | // your logic 81 | } 82 | } 83 | ``` 84 | 85 | ## File upload 86 | 87 | You can use `file` inputs to enable file upload. Example: 88 | 89 | ```razor 90 | 91 | 92 | @model AddAttachment 93 | 94 |
95 | 99 |
100 | ``` 101 | 102 | ```c# 103 | // AddAttachment.cshtml.cs 104 | 105 | public class AddAttachment : HydroComponent 106 | { 107 | [Transient] 108 | public IFormFile DocumentFile { get; set; } 109 | 110 | [Required] 111 | public string DocumentId { get; set; } 112 | 113 | public async Task Save() 114 | { 115 | if (!Validate()) 116 | { 117 | return; 118 | } 119 | 120 | var tempFilePath = GetTempFileLocation(DocumentId); 121 | 122 | // Move your file at tempFilePath to the final storage 123 | // and save that information in your domain 124 | } 125 | 126 | public override async Task BindAsync(PropertyPath property, object value) 127 | { 128 | if (property.Name == nameof(DocumentFile)) 129 | { 130 | // assign the temp file name to the DocumentId 131 | DocumentId = await GetStoredTempFileId((IFormFile)value); 132 | } 133 | } 134 | 135 | private static async Task GetStoredTempFileId(IFormFile file) 136 | { 137 | if (file == null) 138 | { 139 | return null; 140 | } 141 | 142 | var tempFileName = Guid.NewGuid().ToString("N"); 143 | var tempFilePath = GetTempFileLocation(tempFileName); 144 | 145 | await using var readStream = file.OpenReadStream(); 146 | await using var writeStream = File.OpenWrite(tempFilePath); 147 | await readStream.CopyToAsync(writeStream); 148 | 149 | return tempFileName; 150 | } 151 | 152 | private static string GetTempFileLocation(string fileName) => 153 | Path.Combine(Path.GetTempPath(), fileName); 154 | } 155 | ``` 156 | 157 | `DocumentFile` property represents the file that is sent by the user. We need to put `[Transient]` attribute on it, to make sure it's not 158 | serialized, kept on the page, and sent back to the server each time - it would be a lot of data to transfer in case of large files. 159 | 160 | The place where we interact with the uploaded file is the `BindAsync` method. We store the file in a temporary storage 161 | which we can use later when submitting the form. 162 | 163 | > NOTE: Make sure the temporary storage is cleared periodically. 164 | 165 | ### Multiple files 166 | 167 | To support multiple files in one field, you can use the `multiple` attribute: 168 | 169 | ```razor 170 | 171 | 172 | @model AddAttachment 173 | 174 |
175 | 180 |
181 | ``` 182 | 183 | Then your component code would change to: 184 | 185 | 186 | ```c# 187 | // AddAttachment.cshtml.cs 188 | 189 | public class AddAttachment : HydroComponent 190 | { 191 | [Transient] 192 | public IFormFile[] DocumentFiles { get; set; } 193 | 194 | [Required] 195 | public List DocumentIds { get; set; } 196 | 197 | public override async Task BindAsync(PropertyPath property, object value) 198 | { 199 | if (property.Name == nameof(DocumentFiles)) 200 | { 201 | DocumentIds = []; 202 | var files = (IFormFile[])value; 203 | 204 | foreach (var file in files) 205 | { 206 | DocumentIds.Add(await GetStoredTempFileId(file)); 207 | } 208 | } 209 | } 210 | 211 | // rest of the file same as in the previous example 212 | } 213 | ``` 214 | 215 | ## Styling 216 | 217 | `.hydro-request` CSS class is toggled on the elements that are currently in the binding process -------------------------------------------------------------------------------- /docs/content/features/components.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Components 6 | 7 | Hydro components are extended version of View Components from ASP.NET Core. 8 | 9 | To build a component you will need: 10 | - component view (a cshtml file) 11 | - component code-behind (a class that derives from `HydroComponent`) 12 | - (optional) component styles 13 | 14 | The place for keeping the components depends on your project settings. In Razor Pages by default it will be either `~/Pages/Components/` or `~/Components/` folder, but it can be customized. You can decide if you want to create separate folders for each component or not. 15 | 16 | Let's see it in action: 17 | ```c# 18 | // ~/Pages/Components/PageCounter.cshtml.cs 19 | 20 | public class PageCounter : HydroComponent 21 | { 22 | public int Count { get; set; } 23 | 24 | public void Add() 25 | { 26 | Count++; 27 | } 28 | } 29 | ``` 30 | 31 | ```razor 32 | 33 | 34 | @model PageCounter 35 | 36 |
37 | Count: @Model.Count 38 | 41 |
42 | ``` 43 | 44 | Notes about the above code: 45 | 1. Component's view model is set to be the Hydro component. It's because all the state lays there. If you want to extract the model to a separate file, you can do it and reference it as one property on the component. 46 | 2. Each component view must have only one root element, in our case it's the top level `div`. 47 | 3. On the component view you can use `Model` to access your component's state, in our case: `Model.Count` 48 | 4. Use `on` attribute to attach the browser events to the methods on the component. [Read more here](actions). 49 | 50 | ## Usage 51 | 52 | To use your new component, you can render it in your Razor Page (e.g. `Index.cshtml`) or in another Hydro component. There are several ways to do it: 53 | 54 | by calling a custom tag using dash-case notation: 55 | ```razor 56 | 57 | ``` 58 | 59 | by calling a generic tag helper: 60 | 61 | ```razor 62 | 63 | ``` 64 | 65 | or by calling one of the extension methods: 66 | ```razor 67 | @await Html.Hydro("PageCounter") 68 | ``` 69 | ```razor 70 | @(await Html.Hydro()) 71 | ``` 72 | 73 | ## State 74 | 75 | State of the components is serialized, encoded and stored on the rendered page. Whenever there is call from that component to the back-end, the state is attached to the request headers. 76 | 77 | ## Components nesting 78 | 79 | Hydro components can be nested, which means you can put one Hydro component inside the other. When the state of the parent component changes, the nested components won't be updated, so if there is a need to update the nested components, it has to be communicated via events or the key of the component has to change ([key parameter](/features/parameters#key) used for rendering the component). 80 | 81 | ## Same component rendered multiple times on the same view 82 | 83 | It's possible to use the same component multiple times on the same view, but then you have to provide a unique key for each instance. For more details go to the [parameters](/features/parameters#key) page. 84 | 85 | ## Component's lifecycle 86 | 87 | Extensive example of a component to describe the lifecycle: 88 | ```c# 89 | public class EditUserForm : HydroComponent 90 | { 91 | private readonly IDatabase _database; 92 | 93 | public EditUserForm(IDatabase database) 94 | { 95 | _database = database; 96 | 97 | Subscribe(Handle); 98 | } 99 | 100 | public string UserId { get; set; } 101 | 102 | [Required] 103 | public string Name { get; set; } 104 | 105 | public override async Task MountAsync() 106 | { 107 | var formData = ...; // fetch data from database 108 | 109 | Name = formData.Name; 110 | } 111 | 112 | public override void Render() 113 | { 114 | ViewBag.IsLongName = Name.Length > 20; 115 | } 116 | 117 | public async Task Save() 118 | { 119 | await _database.UpdateUser(UserId, Name); // save the data 120 | } 121 | 122 | public void Handle(SystemMessageEvent message) 123 | { 124 | Message = message.Text; 125 | } 126 | } 127 | 128 | ``` 129 | 130 | ### Constructor (optional) 131 | 132 | Use the constructor to initialize your component, inject necessary services or subscribe to a Hydro events. The constructor is called on each request and the injections are done by the ASP.NET Core DI. 133 | 134 | ### Mount or MountAsync 135 | 136 | `Mount` is called only once on when component is instantiated on the page. 137 | 138 | ### Render or RenderAsync 139 | 140 | `Render` is called on each request when this component should render. You can use it to pass additional data calculated on the fly or to get temporary data from the database that you don't want to store in the component persistent state. 141 | 142 | ### Custom actions 143 | 144 | `Save` method is a custom action triggered by the browser's events like `click` or `submit`. 145 | 146 | ### Event handlers 147 | 148 | `Handle` method is used here to catch custom event `SystemMessageEvent` that we subscribed to in the constructor. It's called whenever such event is triggered in the application. -------------------------------------------------------------------------------- /docs/content/features/cookies.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Cookies 6 | 7 | Hydro components provide a simple way to work with cookies in your application. You can read, write, and delete cookies using the `CookieStorage` property on the `HydroComponent` class: 8 | 9 | ```c# 10 | // ThemeSwitcher.cshtml.cs 11 | 12 | public class ThemeSwitcher : HydroComponent 13 | { 14 | public string Theme { get; set; } 15 | 16 | public override void Mount() 17 | { 18 | Theme = CookieStorage.Get("theme", defaultValue: "light"); 19 | } 20 | 21 | public void Switch(string theme) 22 | { 23 | Theme = theme; 24 | CookieStorage.Set("theme", theme); 25 | } 26 | } 27 | ``` 28 | 29 | ## Complex objects 30 | 31 | You can also store complex objects in cookies. Hydro will serialize and deserialize them for you: 32 | 33 | ```c# 34 | // UserSettings.cshtml.cs 35 | 36 | public class UserSettings : HydroComponent 37 | { 38 | public UserSettingsStorage Storage { get; set; } 39 | 40 | public override void Mount() 41 | { 42 | Storage = CookieStorage.Get("settings", defaultValue: new UserSettingsStorage()); 43 | } 44 | 45 | public void SwitchTheme(string theme) 46 | { 47 | Storage.Theme = theme; 48 | CookieStorage.Set("settings", Storage); 49 | } 50 | 51 | public class UserSettingsStorage 52 | { 53 | public string StartupPage { get; set; } 54 | public string Theme { get; set; } 55 | } 56 | } 57 | ``` 58 | 59 | ## Customizing cookies 60 | 61 | Default expiration date is 30 days, but can be customized with expiration parameter: 62 | 63 | ```c# 64 | CookieStorage.Set("theme", "light", expiration: TimeSpan.FromDays(7)); 65 | ``` 66 | 67 | You can further customize the cookie settings by passing an instance of `CookieOptions` to the `Set` method: 68 | 69 | ```c# 70 | CookieStorage.Set("theme", "light", encrypt: false, new CookieOptions { Secure = true }); 71 | ``` 72 | 73 | ## Encryption 74 | 75 | It's possible to encode the cookie value by setting the `encryption` parameter to `true`: 76 | 77 | ```c# 78 | CookieStorage.Set("theme", "light", encryption: true); 79 | ``` 80 | 81 | ```c# 82 | CookieStorage.Get("theme", encryption: true); 83 | ``` -------------------------------------------------------------------------------- /docs/content/features/errors-handling.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Errors handling 6 | 7 | In regular cases, expected errors handling can be done manually by `try/catch` statements. 8 | But in cases of unhandled exceptions we want to gracefully inform the user of the situation. 9 | 10 | One of the ways to do it when working with Hydro components is to create a global messages 11 | component for showing alerts, that can be rendered in your layout. Such component could 12 | subscribe to your own custom events with messages, but it also can listen to built-in 13 | Hydro's unhandled error event: `UnhandledHydroError`. 14 | 15 | ```c# 16 | public class Toasts : HydroComponent 17 | { 18 | public List ToastsList { get; set; } = new(); 19 | 20 | public Toasts() 21 | { 22 | Subscribe(Handle); 23 | } 24 | 25 | private void Handle(UnhandledHydroError data) => 26 | ToastsList.Add(new Toast( 27 | Id: Guid.NewGuid().ToString("N"), 28 | Message: data.Message, 29 | Type: ToastType.Error 30 | )); 31 | 32 | public void Close(string id) => 33 | ToastsList.RemoveAll(t => t.Id == id); 34 | 35 | public record Toast(string Id, string Message, ToastType Type); 36 | } 37 | ``` 38 | 39 | Hydro will send `UnhandledHydroError` in case of unhandled error and by default 40 | it will contain the response from the server produced by ASP.NET MVC for exceptions, 41 | which might be to expressive. To customize that you can configure the ASP.NET MVC exception 42 | handling: 43 | 44 | ```c# 45 | app.UseExceptionHandler(b => b.Run(async context => 46 | { 47 | if (!context.IsHydro()) 48 | { 49 | context.Response.Redirect("/Error"); 50 | return; 51 | } 52 | 53 | var contextFeature = context.Features.Get(); 54 | switch (contextFeature?.Error) 55 | { 56 | // custom cases for custom exception types if needed 57 | 58 | default: 59 | context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; 60 | 61 | await context.Response.WriteAsJsonAsync(new UnhandledHydroError( 62 | Message: "There was a problem with this operation and it wasn't finished", 63 | Data: null 64 | )); 65 | 66 | return; 67 | } 68 | })); 69 | ``` 70 | 71 | In the code above we are creating a JSON response containing `UnhandledHydroError` event that 72 | will be consumed in our `Toasts` component. -------------------------------------------------------------------------------- /docs/content/features/events.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Events 6 | 7 | Events in Hydro are a powerful feature for communication between components. Components can publish events that other 8 | components can subscribe to. The use of events allows components to remain decoupled and promotes a clean architecture. 9 | 10 | Here is an example: 11 | 12 | ```csharp 13 | // CountChangedEvent.cs 14 | 15 | public record CountChangedEvent(int Count); 16 | ``` 17 | 18 | To trigger an event from a Hydro component, use a `Dispatch` method in your action method: 19 | 20 | ```csharp 21 | // Counter.cshtml.cs 22 | 23 | public class Counter : HydroComponent 24 | { 25 | public int Count { get; set; } 26 | 27 | public void Add() 28 | { 29 | Count++; 30 | Dispatch(new CountChangedEvent(Count)); 31 | } 32 | } 33 | ``` 34 | 35 | To subscribe to an event in a parent component, use there the `Subscribe` method: 36 | 37 | ```csharp 38 | // Summary.cshtml.cs 39 | 40 | public class Summary : HydroComponent 41 | { 42 | public Summary() 43 | { 44 | Subscribe(Handle); 45 | } 46 | 47 | public int CountSummary { get; set; } 48 | 49 | public void Handle(CountChangedEvent data) 50 | { 51 | CountSummary = data.Count; 52 | } 53 | } 54 | ``` 55 | 56 | When a component's subscription is triggered by an event, the component will be rerendered. 57 | 58 | ## Dispatching 59 | 60 | As we saw in the above example, one of the ways to dispatch an event is to call `Dispatch` method: 61 | 62 | ```csharp 63 | // Counter.cshtml.cs 64 | 65 | public class Counter : HydroComponent 66 | { 67 | public int Count { get; set; } 68 | 69 | public void Add() 70 | { 71 | Count++; 72 | Dispatch(new CountChangedEvent(Count)); 73 | } 74 | } 75 | ``` 76 | 77 | This is fine in most scenarios when dispatching is not the only one operation we want to do. 78 | But sometimes your only intent is to dispatch an event: 79 | 80 | ```csharp 81 | // ProductList.cshtml.cs 82 | 83 | public class ProductList : HydroComponent 84 | { 85 | public void Add() => 86 | Dispatch(new OpenAddModal()); 87 | } 88 | ``` 89 | 90 | In this case using a Hydro action might be an overkill, since it will cause an unnecessary additional request and rendering of the component. To avoid that, you can dispatch actions straight from your client code by using `Model.Client.Dispatch`: 91 | 92 | ```razor 93 | 97 | ``` 98 | 99 | Now, after clicking the button, the event `OpenAddModal` will be triggered without calling Hydro action first. 100 | 101 | Another way to avoid the extra render of the component is to use `[SkipOutput]` attribute on the Hydro action: 102 | 103 | ```csharp 104 | // ProductList.cshtml.cs 105 | 106 | public class ProductList : HydroComponent 107 | { 108 | [SkipOutput] 109 | public void Add() => 110 | Dispatch(new OpenAddModal()); 111 | } 112 | ``` 113 | 114 | > **_NOTE:_** When using `[SkipOutput]` any changes to the state won't be persisted. 115 | 116 | ## Synchronous vs asynchronous 117 | 118 | By default, Hydro events are dispatched synchronously, which means they will follow one internal operation in Hydro. 119 | 120 | Let's take a look at this example of a synchronous event: 121 | 122 | ```c# 123 | public void Add() 124 | { 125 | Count++; 126 | Dispatch(new CountChangedEvent(Count)); 127 | } 128 | ``` 129 | 130 | `Add` triggers the event synchronously, so the button that triggers this action will be disabled until both the action and the event executions are done. 131 | 132 | Now, let's compare it with the asynchronous way: 133 | 134 | ```c# 135 | public void Add() 136 | { 137 | Count++; 138 | Dispatch(new CountChangedEvent(Count), asynchronous: true); 139 | } 140 | ``` 141 | 142 | `Add` triggers the event asynchronously, so the button that triggers this action will be disabled until the action is done. The event execution won't be connected with the action's pipeline and will be run on its own. 143 | 144 | 145 | ## Event scope 146 | 147 | By default, the events are dispatched only to their parent component. To publish a global event use the following 148 | method: 149 | 150 | ```c# 151 | Dispatch(new ShowMessage(Content), Scope.Global); 152 | ``` 153 | 154 | or 155 | 156 | ```c# 157 | DispatchGlobal(new ShowMessage(Content)); 158 | ``` 159 | 160 | Any component that subscribes for `ShowMessage` will be notified, no matter the component's location. 161 | 162 | ## Inlined subscription 163 | 164 | The following code uses inlined subscription and will work same as passing the method: 165 | 166 | ```csharp 167 | // Summary.cshtml.cs 168 | 169 | public class Summary : HydroComponent 170 | { 171 | public Summary() 172 | { 173 | Subscribe(data => CountSummary = data.Count); 174 | } 175 | 176 | public int CountSummary { get; set; } 177 | } 178 | ``` 179 | 180 | ## Empty subscription 181 | 182 | Sometimes we only want to rerender the component when an event occurs: 183 | 184 | ```csharp 185 | // ProductList.cshtml.cs 186 | 187 | public class ProductList : HydroComponent 188 | { 189 | public ProductList() 190 | { 191 | Subscribe(); 192 | } 193 | 194 | public override void Render() 195 | { 196 | // When ProductAddedEvent occurs, component will be rerendered 197 | } 198 | } 199 | ``` 200 | 201 | ## Event subject 202 | 203 | There might be a situation where you want to filter the events you receive in your subscription handler. It means that 204 | your component subscribes to an event, but handles it only when it contains a certain flag. That flag can be any string 205 | and is called a `subject`. 206 | 207 | You can imagine a page with multiple lists of todos. Each list is a Hydro component that listens to events like `TodoAdded`, 208 | `TodoRemoved` or `TodoEdited`. When a todo is removed on one list, you don't want all the other lists to receive and react to that event, but only 209 | the list that contained that todo item. This is solved in Hydro by using `subject` parameter, which in this case will be the list's id. 210 | When `TodoAdded`, `TodoRemoved` or `TodoEdited` are dispatched, `subject` is set to their list's id. The list component subscribes to those 211 | events with `subject` set to the their list's id. 212 | 213 | Example: 214 | 215 | ```c# 216 | // Todo.cshtml.cs 217 | 218 | public class Todo : HydroComponent 219 | { 220 | public string TodoId { get; set; } 221 | public string ListId { get; set; } 222 | public string Text { get; set; } 223 | public bool IsDone { get; set; } 224 | 225 | public void Remove(string id) 226 | { 227 | DispatchGlobal(new TodoRemoved { TodoId }, subject: ListId); 228 | } 229 | } 230 | ``` 231 | 232 | ```c# 233 | // TodoList.cshtml.cs 234 | 235 | public class TodoList : HydroComponent 236 | { 237 | public string ListId { get; set; } 238 | public List Todos { get; set; } 239 | 240 | public TodoList() 241 | { 242 | Subscribe(subject: () => ListId, Handle); 243 | } 244 | 245 | public void Handle(TodoRemoved data) 246 | { 247 | // will be called only when subject is ListId 248 | Todos.RemoveAll(todo => todo.TodoId == data.TodoId); 249 | } 250 | } 251 | ``` 252 | 253 | In `Subscribe` method call `subject` parameter is a `Func` instead of `string`, 254 | because its value could be taken from component's properties that are not set yet, since it's 255 | a constructor. 256 | 257 | > NOTE: If you subscribe for an event without specifying the subject, it will catch all the events 258 | of that type, no matter if they were dispatched with subject or not. -------------------------------------------------------------------------------- /docs/content/features/form-validation.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Form validation 6 | 7 | Hydro components provide model validation capabilities similar to traditional ASP.NET Core MVC models. Use Data Annotations to define validation rules in your components. 8 | 9 | ```csharp 10 | // ProductForm.cshtml.cs 11 | 12 | public class ProductForm : HydroComponent 13 | { 14 | [Required, MaxLength(50)] 15 | public string Name { get; set; } 16 | 17 | public async Task Submit() 18 | { 19 | if (!Validate()) 20 | { 21 | return; 22 | } 23 | 24 | // your submit logic 25 | } 26 | } 27 | ``` 28 | 29 | ```razor 30 | 31 | 32 | @model ProductForm 33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 |
41 | ``` 42 | 43 | ## Validating nested models and collections 44 | 45 | Data Annotations don't offer a way to validate nested models or collections, that's why Hydro provides custom validators to handle these scenarios. 46 | 47 | Nested model: 48 | 49 | ```csharp 50 | // ProductForm.cshtml.cs 51 | 52 | public class ProductForm : HydroComponent 53 | { 54 | [ValidateObject] 55 | public ProductData Product { get; set; } 56 | 57 | // ... 58 | } 59 | 60 | public class ProductData 61 | { 62 | [Required, MaxLength(50)] 63 | public string Name { get; set; } 64 | 65 | [Range(0, 100)] 66 | public decimal Price { get; set; } 67 | } 68 | ``` 69 | 70 | Collection: 71 | 72 | ```csharp 73 | // ProductForm.cshtml.cs 74 | 75 | public class InvoiceForm : HydroComponent 76 | { 77 | [ValidateCollection] 78 | public List Lines { get; set; } 79 | 80 | // ... 81 | } 82 | 83 | public class LineData 84 | { 85 | [Required, MaxLength(50)] 86 | public string Name { get; set; } 87 | 88 | [Range(0, 100)] 89 | public decimal Price { get; set; } 90 | } 91 | ``` 92 | 93 | ## Custom validation 94 | 95 | It's possible to execute also custom validation. For example: 96 | 97 | ```csharp 98 | // Counter.cshtml.cs 99 | 100 | public class Counter : HydroComponent 101 | { 102 | public int Count { get; set; } 103 | 104 | public void Add() 105 | { 106 | if (Count > 5) 107 | { 108 | ModelState.AddModelError(nameof(Count), "Value is too high"); 109 | return; 110 | } 111 | 112 | Count++; 113 | } 114 | } 115 | ``` 116 | 117 | Attaching Fluent Validation: 118 | 119 | ```csharp 120 | // Counter.cshtml.cs 121 | 122 | public class Counter(IValidator validator) : HydroComponent 123 | { 124 | public int Count { get; set; } 125 | 126 | public void Add() 127 | { 128 | if (!this.Validate(validator)) 129 | { 130 | return; 131 | } 132 | 133 | Count++; 134 | } 135 | 136 | public class Validator : AbstractValidator 137 | { 138 | public Validator() 139 | { 140 | RuleFor(c => c.Count).LessThan(5); 141 | } 142 | } 143 | } 144 | 145 | // HydroValidationExtensions.cs 146 | 147 | public static class HydroValidationExtensions 148 | { 149 | public static bool Validate(this TComponent component, IValidator validator) where TComponent : HydroComponent 150 | { 151 | component.IsModelTouched = true; 152 | var result = validator.Validate(component); 153 | 154 | foreach (var error in result.Errors) 155 | { 156 | component.ModelState.AddModelError(error.PropertyName, error.ErrorMessage); 157 | } 158 | 159 | return result.IsValid; 160 | } 161 | } 162 | 163 | ``` -------------------------------------------------------------------------------- /docs/content/features/js.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Using JavaScript 6 | 7 | With Hydro you can create web applications without writing JavaScript, but 8 | sometimes there are very specific use cases where using small portions of JavaScript is needed to improve 9 | the user experience. Those use cases usually refer to creating reusable components, not the domain specific components. Examples where JavaScript is a nice addition to Hydro: 10 | - selecting the content of an element when focused 11 | - operating on existing JS libraries, like maps 12 | - changing the currently highlighted element in a list using arrows 13 | - ... 14 | 15 | In practice, there shouldn't be many places where JS is used, but it's good to have 16 | an option to utilize it when needed. 17 | 18 | ## Using Alpine.js 19 | 20 | Hydro is using [Alpine.js](https://alpinejs.dev/) as the backbone for handling all interactions on the client side, 21 | and it enables by default all the great features from that library. It means you can create 22 | Hydro components that utilize Alpine.js directives like [x-on](https://alpinejs.dev/directives/on), [x-data](https://alpinejs.dev/directives/data), [x-text](https://alpinejs.dev/directives/text), [x-ref](https://alpinejs.dev/directives/ref) and all the rest. 23 | 24 | Example. Select the content of an input when focused: 25 | ```razor 26 | @model Search 27 | 28 |
29 | Count: @Model.Count 30 | 31 |
32 | ``` 33 | 34 | Example. Create local JS state and operate on it. 35 | ```razor 36 | @model Search 37 | 38 |
39 |
40 | 41 | 42 |
43 |
44 | ``` 45 | 46 | The only limitation is that you can't set a custom `x-data` attribute on the root element, that's why in the above example a nested div is introduced. 47 | 48 | ## Using Hydro 49 | 50 | You can execute JavaScript code using [Hydro action handlers](/features/actions) in views or components code-behind. 51 | 52 | ### Example of invoking JavaScript expression in the view: 53 | 54 | ```razor 55 | 56 | 57 | @model Search 58 | 59 |
60 | 65 |
66 | ``` 67 | 68 | ### Example of invoking JavaScript expression in the action handler: 69 | 70 | ```c# 71 | // Counter.cshtml.cs 72 | 73 | public class Counter : HydroComponent 74 | { 75 | public int Count { get; set; } 76 | 77 | public void Add() 78 | { 79 | Count++; 80 | Client.ExecuteJs($"console.log({Count})"); 81 | } 82 | } 83 | ``` 84 | 85 | ### Execution Context 86 | 87 | The context of execution the JS expression is the component DOM element, and can be accessed via `this`. Example: 88 | 89 | ```c# 90 | // ProductDialog.cshtml.cs 91 | 92 | public class ProductDialog : HydroComponent 93 | { 94 | [SkipOutput] 95 | public void Close() 96 | { 97 | DispatchGlobal(new CloseDialog(nameof(ProductDialog))); 98 | Client.ExecuteJs($"this.remove()"); 99 | } 100 | } 101 | ``` 102 | 103 | In the above example, first we dispatch an event to notify dialogs container to change the state, and then we invoke JS expression 104 | to remove dialog component DOM element immediately, without waiting for the state update. 105 | 106 | ## Generic events 107 | 108 | Hydro emits JavaScript events on `document` element during certain lifecycle moments of the component: 109 | - `HydroComponentInit` - triggered once component is initialized 110 | - `HydroComponentUpdate` - triggered after component content is updated 111 | - `HydroLocation`- triggered when the url changes via [hydro-link](navigation.html#navigation-via-links) or [Location](navigation.html#navigation-initiated-in-components-without-page-reload) method 112 | 113 | To catch these events you can use `document.addEventListener`: 114 | 115 | ```js 116 | document.addEventListener('HydroComponentInit', function (e) { 117 | console.log('Component initialized', e.detail); 118 | }); 119 | ``` -------------------------------------------------------------------------------- /docs/content/features/long-polling.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Long polling 6 | 7 | Hydro provides a feature to poll the component's action at regular intervals. It is useful when you want to make sure that in one of the places on the page you're displaying the most recent data. 8 | 9 | To enable long polling on an action, decorate it with `[Poll]` attribute. Default interval is set to 3 seconds (`3_000` milliseconds). To customize it, change the `Interval` property to the desired value in milliseconds. Actions decorated with `[Poll]` attribute have to be parameterless. 10 | 11 | In the example below the `Refresh` action will be called every 60 seconds: 12 | 13 | ```csharp 14 | public class NotificationsIndicator(INotifications notifications) : HydroComponent 15 | { 16 | public int NotificationsCount { get; set; } 17 | 18 | [Poll(Interval = 60_000)] 19 | public async Task Refresh() 20 | { 21 | NotificationsCount = await notifications.GetCount(); 22 | } 23 | } 24 | ``` 25 | 26 | ```razor 27 | @model NotificationsIndicator 28 | 29 |
30 | Notifications: @Model.NotificationsCount 31 |
32 | ``` 33 | 34 | ## Polling pauses 35 | 36 | When a page with a polling component is hidden (not in the currently open tab), polling will stop and restart once the tab becomes visible again. -------------------------------------------------------------------------------- /docs/content/features/navigation.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Navigation 6 | 7 | There are 3 kinds of managed navigation in applications using Hydro: 8 | 1. Navigation via links. 9 | 2. Navigation initiated in components (without page reload). 10 | 3. Navigation initiated in components (with full page reload). 11 | 12 | ## Navigation via links 13 | 14 | With `hydro-link` attribute relative links in your application can be loaded in the background and applied to the current document instead of doing the full page reload. 15 | 16 | Examples: 17 | 18 | Attribute applied directly on a link: 19 | ```html 20 | My page 21 | ``` 22 | 23 | Attribute applied directly on a parent of the links: 24 | ```html 25 | 29 | ``` 30 | 31 | ## Navigation initiated in components (without page reload) 32 | 33 | Let's take a look at he following code: 34 | 35 | ```csharp 36 | // MyPage.cshtml.cs 37 | 38 | public class MyPage : HydroComponent 39 | { 40 | public void About() 41 | { 42 | Location(Url.Page("/About/Index")); 43 | } 44 | } 45 | ``` 46 | 47 | ## Choosing the target selector during navigation 48 | 49 | Often when navigating to another page, the only part of the page that is changing is the content section, while layout remains the same. In those cases 50 | we can disable layout rendering, send only the content section, and instruct Hydro where to put it. It can be achieved by using `HydroTarget`: 51 | 52 | ```razor 53 | // Layout.cshtml 54 | 55 | 56 | 57 | 58 | Test 59 | 60 | 61 | 62 | 63 | 64 | 65 | 69 | 70 |
71 | @RenderBody() 72 |
73 | 74 | 75 | 76 | ``` 77 | 78 | ```razor 79 | // Index.cshtml 80 | 81 | @{ 82 | if (HttpContext.IsHydro()) 83 | { 84 | Layout = null; 85 | this.HydroTarget("#content"); 86 | } 87 | } 88 | 89 | Content of the page 90 | ``` 91 | 92 | We are using here `#content` as the target, but it's also possible to use Hydro's predefined identifier `#hydro`, example: 93 | 94 | ```razor 95 |
96 | @RenderBody() 97 |
98 | 99 | or 100 | 101 |
102 | @RenderBody() 103 |
104 | ``` 105 | 106 | ```c# 107 | this.HydroTarget(); // selector will be set to #hydro 108 | ``` 109 | 110 | `HydroTarget` has also an optional parameter `title`, which is used to set the title of loaded page. Example: 111 | 112 | ```razor 113 | // Index.cshtml 114 | 115 | @{ 116 | if (HttpContext.IsHydro()) 117 | { 118 | Layout = null; 119 | this.HydroTarget(title: "Home"); 120 | } 121 | } 122 | 123 | Content of the page 124 | ``` 125 | 126 | ### Passing the payload 127 | 128 | Sometimes it's needed to pass a payload object from one page to another. For such cases, there is a second optional parameter called `payload`: 129 | 130 | ```csharp 131 | // Products.cshtml.cs 132 | 133 | public class Products : HydroComponent 134 | { 135 | public HashSet SelectedProductsIds { get; set; } 136 | 137 | // ... product page logic 138 | 139 | public void AddToCart() 140 | { 141 | Location(Url.Page("/Cart/Index"), new CartPayload(SelectedProductsIds)); 142 | } 143 | } 144 | ``` 145 | ```csharp 146 | // CartPayload.cs 147 | 148 | public record CartPayload(HashSet ProductsIds); 149 | ``` 150 | 151 | ### Reading the payload 152 | 153 | Use the following method to read the previously passed payload: 154 | 155 | ```csharp 156 | // CartSummary.cshtml.cs 157 | 158 | public class CartSummary : HydroComponent 159 | { 160 | public CartPayload Payload { get; set; } 161 | 162 | public override void Mount() 163 | { 164 | Payload = GetPayload(); 165 | } 166 | 167 | // ... 168 | } 169 | ``` 170 | 171 | 172 | ## Navigation initiated in components (with full page reload) 173 | 174 | If full page reload is needed, use the `Redirect` method: 175 | 176 | ```csharp 177 | // MyPage.cshtml.cs 178 | 179 | public class MyPage : HydroComponent 180 | { 181 | public void Logout() 182 | { 183 | // logout logic 184 | 185 | Redirect(Url.Page("/Home/Index")); 186 | } 187 | } 188 | ``` -------------------------------------------------------------------------------- /docs/content/features/parameters.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Parameters 6 | 7 | Parameters are public properties on a component and are used to store component's state. They also allow the passing of 8 | data or settings from a parent component to a child component. Parameters can include any types of values, such as 9 | integers, strings, and complex objects. 10 | 11 | Let's look at the Counter component: 12 | 13 | ```csharp 14 | // Counter.cshtml.cs 15 | 16 | public class Counter : HydroComponent 17 | { 18 | public int Count { get; set; } 19 | } 20 | ``` 21 | 22 | The `Count` property can be set by a parameter using dash-case notation: 23 | 24 | ```razor 25 | 26 | 27 | 28 | ``` 29 | 30 | or 31 | 32 | ```razor 33 | 34 | 35 | 36 | ``` 37 | 38 | or 39 | 40 | ```razor 41 | 42 | 43 | @await Html.Hydro("Counter", new { Count = 10 }) 44 | ``` 45 | 46 | > NOTE: For multi-word properties, `Data` word cannot be used as the leading one, for example `DataSource`. `Database` 47 | > is fine, since here `Data` is not a word, but just a part of the word. 48 | 49 | ## Transient properties 50 | 51 | Sometimes there is no need to persist the property value across the request because its value is valid only within 52 | the current request, for example a message after successful saving. 53 | 54 | Another use case is handling the list of rows that you want to show in a table. If there are many rows and they should 55 | be 56 | fetched from the database on each parameter change, you probably don't want to keep the state of those rows across 57 | requests. 58 | 59 | There are two ways to define a transient property: 60 | 61 | ### `Transient` attribute 62 | 63 | ```csharp 64 | public class ProductForm : HydroComponent 65 | { 66 | [Transient] 67 | public bool IsSuccess { get; set; } 68 | } 69 | ``` 70 | 71 | ### Property without setter 72 | 73 | ```csharp 74 | public class ProductForm : HydroComponent 75 | { 76 | public DateTime CurrentDate => DateTime.Now; 77 | } 78 | ``` 79 | 80 | ## Hiding properties from a parameter list 81 | 82 | By default, all the properties become available as parameters when rendering a component. 83 | If you want to hide some of them, you can use the `HtmlAttributeNotBound` attribute: 84 | 85 | ```csharp 86 | public class ProductForm : HydroComponent 87 | { 88 | public string Name { get; set; } 89 | 90 | [HtmlAttributeNotBound] 91 | public List Currencies { get; set; } 92 | } 93 | ``` 94 | 95 | Now you can pass only the Name, but Currencies will not be available: 96 | 97 | ```razor 98 | @* Ok *@ 99 | 100 | 101 | @* Currencies won't be passed *@ 102 | 103 | ``` 104 | 105 | ## State of the parameters in time 106 | 107 | The values are passed to the component only once and any update to the parameters won't refresh the parent component. If 108 | you want the component to refresh its state, you would have to use events or change the [key parameter](#key) of the 109 | component. 110 | 111 | ## Key 112 | 113 | When rendering a Hydro component, you can provide an optional `key` argument: 114 | 115 | ```razor 116 | 117 | ``` 118 | 119 | or 120 | 121 | ```razor 122 | 123 | ``` 124 | 125 | It's used when you have multiple components of the same type on the page to make it possible to distinguish them during 126 | DOM updates. 127 | 128 | Usage examples: 129 | 130 | ```razor 131 | @foreach (var item in Items) 132 | { 133 | 134 | } 135 | ``` 136 | 137 | or 138 | 139 | ```razor 140 | 141 | 142 | ``` 143 | 144 | You can also use `key` to force re-render of your component: 145 | 146 | ```razor 147 | 148 | ``` 149 | 150 | Where `CalculateHashCode` is an extension method returning unique hash code for the collection. 151 | Now, whenever `Model.Items` changes, Hydro will re-render the component `Items` and pass new parameter. 152 | 153 | ## Caching 154 | 155 | Let's imagine you need to show list of customers in a table. It's good to use caching per request for such rows data, 156 | because you might want to access your filtered or sorted list in your view and actions, and you don't want to fetch the 157 | data each time you access it. 158 | Hydro has a solution for that which is built-in caching. To enable caching, create a read-only property that uses 159 | `Cache` method. 160 | If the property name is called `Customers`, you can get value either in view or component from the cache using 161 | `Customers.Value`. Example: 162 | 163 | ```c# 164 | // CustomerList.cshtml.cs 165 | 166 | public class CustomerList(IDatabase database) : HydroComponent 167 | { 168 | public string SearchPhrase { get; set; } 169 | 170 | public HashSet Selection { get; set; } = new(); 171 | 172 | public Cache>> Customers => Cache(async () => 173 | { 174 | var query = database.Query(); 175 | 176 | if (!string.IsNullOrWhiteSpace(SearchPhrase)) 177 | { 178 | query = query.Where(p => p.Name.Contains(SearchPhrase)); 179 | } 180 | 181 | return await query.ToListAsync(); 182 | }); 183 | 184 | public async Task Print() 185 | { 186 | var customers = await Customers.Value; 187 | 188 | if (!customers.Any()) 189 | { 190 | return; 191 | } 192 | 193 | var customerIds = customers.Select(c => c.Id).ToList(); 194 | Location(Url.Page("/Customers/Print"), new CustomersPrintPayload(customerIds)); 195 | } 196 | } 197 | ``` 198 | 199 | ```razor 200 | 201 | 202 | @model CustomerList 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | @foreach (var customer in await Model.Customers.Value) 214 | { 215 | 216 | 217 | 218 | } 219 | 220 |
Customer name
@customer.Name
221 | ``` -------------------------------------------------------------------------------- /docs/content/features/ui-utils.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # User interface utilities 6 | 7 | ## Styles 8 | 9 | ### `.hydro-request` 10 | CSS class added to elements that triggered a Hydro operation and is currently being processed 11 | 12 | ### `.hydro-loading` 13 | CSS class added to `body` element when a page is loading using hydro-link functionality 14 | 15 | ### `disabled` 16 | Attribute added to elements that triggered a Hydro operation and is currently being processed 17 | 18 | ## Styling examples: 19 | 20 | ### Page loading indicator 21 | 22 | ```html 23 | 46 | 47 | 48 |
49 | 50 | ``` 51 | 52 | ### Hydro action loading indicator 53 | 54 | ```css 55 | /* MyComponent.cshtml.css */ 56 | 57 | .loader { 58 | display: none; 59 | } 60 | 61 | .hydro-request .loader { 62 | display: inline-block; 63 | } 64 | ``` 65 | 66 | ```razor 67 | 68 | 69 | @model MyComponent 70 | 71 | 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/content/features/xsrf-token.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Anti-forgery token 6 | 7 | Hydro supports mechanism built-in to ASP.NET Core to prevent prevent Cross-Site Request Forgery (XSRF/CSRF) attacks. 8 | 9 | In the configuration of services use: 10 | ```c# 11 | services.AddHydro(options => 12 | { 13 | options.AntiforgeryTokenEnabled = true; 14 | }); 15 | ``` 16 | 17 | Make sure you've also added `meta` tag to the layout's `head`: 18 | ```html 19 | 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/content/home-layout-wrapper.css: -------------------------------------------------------------------------------- 1 | .home-wrapper { 2 | margin: 0 auto; 3 | position: relative; 4 | width: 100%; 5 | padding: 32px 48px 96px; 6 | } 7 | 8 | @media (min-width: 960px) { 9 | .home-wrapper { 10 | padding: 32px 64px 96px; 11 | } 12 | } 13 | 14 | @media (min-width: 1280px) { 15 | .home-wrapper { 16 | padding: 32px 0 96px; 17 | max-width: 1152px; 18 | } 19 | } 20 | 21 | .code-items { 22 | display: flex; 23 | flex-wrap: wrap; 24 | margin: -8px; 25 | } 26 | 27 | .code-item { 28 | padding: 8px; 29 | width: 100%; 30 | } 31 | 32 | @media (min-width: 768px) { 33 | .code-item{ 34 | width: calc(100% / 2); 35 | } 36 | } 37 | 38 | .sponsoring { 39 | display: flex; 40 | justify-content: center; 41 | gap: 1rem; 42 | padding-top: 4rem; 43 | } 44 | 45 | .sponsor { 46 | display: inline-block; 47 | text-align: center; 48 | font-weight: 600; 49 | white-space: nowrap; 50 | transition: color .25s,border-color .25s,background-color .25s; 51 | border-radius: 20px; 52 | padding: 0 20px; 53 | line-height: 38px; 54 | text-decoration: none; 55 | font-size: 14px; 56 | border: 1px solid var(--vp-button-sponsor-border); 57 | color: var(--vp-button-sponsor-text); 58 | background-color: var(--vp-button-sponsor-bg); 59 | } 60 | 61 | .sponsor:hover { 62 | border-color: var(--vp-button-sponsor-hover-border); 63 | color: var(--vp-button-sponsor-hover-text); 64 | background-color: var(--vp-button-sponsor-hover-bg); 65 | } -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Hydro" 7 | text: "Create .NET apps with SPA feeling without JS" 8 | tagline: Bring stateful and reactive components to ASP.NET Core without writing JavaScript 9 | image: 10 | src: /logo.png 11 | alt: Hydro 12 | actions: 13 | - theme: brand 14 | text: Get started 15 | link: /introduction/getting-started 16 | - theme: brand 17 | text: Why Hydro 18 | link: /introduction/motivation 19 | - theme: brand 20 | text: Toolkit 21 | link: https://toolkit.usehydro.dev/ 22 | 23 | features: 24 | - icon: ♥️ 25 | title: Razor Pages and MVC 26 | details: Use familiar, server-side rendering strategy that has been a foundation of .NET web development for many years. 27 | - icon: 🧩️ 28 | title: Components 29 | details: Build stateful and interactive components in an intuitive way, and use them in your Razor Pages or MVC views. 30 | - icon: ⚡️ 31 | title: Great user experience without JS 32 | details: No full page reloads. After each action only update parts of the page that changed. No writing JavaScript required, but possible if needed. 33 | --- 34 | 35 | 38 | 39 |
40 | 41 | ### Quick example: binding 42 | 43 | ```razor 44 | 45 | 46 | @model NameForm 47 | 48 |
49 | 50 |
Hello @Model.Name
51 |
52 | ``` 53 | 54 | ```c# 55 | // NameForm.cs 56 | 57 | public class NameForm : HydroComponent 58 | { 59 | public string Name { get; set; } 60 | } 61 | ``` 62 | 63 | ### Quick example: validation 64 | 65 | ```razor 66 | 67 | 68 | @model NameForm 69 | 70 |
71 | 72 | 73 | 74 | 75 | 76 | @if (Model.Message != null) 77 | { 78 |
@Model.Message
79 | } 80 |
81 | ``` 82 | 83 | ```c# 84 | // NameForm.cs 85 | 86 | public class NameForm : HydroComponent 87 | { 88 | [Required, MaxLength(50)] 89 | public string Name { get; set; } 90 | 91 | public string Message { get; set; } 92 | 93 | public void Save() 94 | { 95 | if (!Validate()) 96 | { 97 | return; 98 | } 99 | 100 | Message = "Success!"; 101 | Name = ""; 102 | } 103 | } 104 | ``` 105 | 106 | ### Quick example: calling actions 107 | 108 | ```razor 109 | 110 | 111 | @model Counter 112 | 113 |
114 | Count: @Model.Count 115 | 116 | 119 |
120 | ``` 121 | 122 | ```c# 123 | // Counter.cs 124 | 125 | public class Counter : HydroComponent 126 | { 127 | public int Count { get; set; } 128 | 129 | public void Add(int value) 130 | { 131 | Count += value; 132 | } 133 | } 134 | ``` 135 | 136 |
137 | 138 |
139 | 140 |
-------------------------------------------------------------------------------- /docs/content/introduction/comparisons.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Comparisons 6 | 7 | Hydro provides a flexible way to create web applications, but there are other frameworks 8 | providing similar functionalities that you can use together with .NET, so it's good to understand the differences 9 | between them before the final choice. 10 | 11 | ## Blazor 12 | 13 | Blazor introduced new component model called Razor Components that is quite different 14 | from the one used in regular Razor Pages or MVC. It allows to do similar things as Hydro, 15 | but it uses different techniques to handle user interaction and component state. 16 | 17 | - Blazor Server - using web sockets to handle state and exchange information between server and client 18 | - Blazor WebAssembly - using web assembly to run your client .NET code in the browser 19 | 20 | Hydro doesn't use neither web sockets or web assembly. Hydro utilizes regular HTTP request/response model 21 | with rendering on the back-end side, morphing the client HTML when needed, and is keeping the state on the page instead in the connection scope. 22 | 23 | We believe the Hydro stack is simple, reliable, easy to debug and easy to scale, using well-known and well-tested solutions. 24 | 25 | ## HTMX 26 | 27 | HTMX is a library for handling interactions between client and server. You can for example add event handlers 28 | to your document that will call the back-end and replace the content of chosen elements, so it also 29 | adds a "SPA feeling" to your web application, since the content can be updated without full-page reload. 30 | 31 | The main difference from Hydro is lack of state management or concept of components that keep the state. In Hydro 32 | you can define a component with set of properties that will be persisted across the requests without any additional configuration. 33 | 34 | ## Turbo 35 | 36 | Turbo is similar to HTMX with small differences. It introduces streams which allow you to control the content replacement from the back-end side. 37 | 38 | The differences between Hydro and Turbo are similar to the ones between Hydro and HTMX. -------------------------------------------------------------------------------- /docs/content/introduction/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Getting started 6 | 7 | Create your web application with the following command. If you already have an app using ASP.NET Core 6+ Razor Pages / MVC project, you can skip this step. 8 | 9 | ```console 10 | dotnet new webapp -o MyApp 11 | cd MyApp 12 | ``` 13 | 14 | Install Hydro [NuGet package](https://www.nuget.org/packages/Hydro/): 15 | ```console 16 | dotnet add package Hydro 17 | ``` 18 | 19 | In application's startup code (either `Program.cs` or `Startup.cs`) add: 20 | 21 | ```c# 22 | builder.Services.AddHydro(); 23 | 24 | ... 25 | 26 | app.UseHydro(); 27 | ``` 28 | 29 | > **_NOTE:_** Make sure that `UseHydro` is called after `UseStaticFiles` and `UseRouting`, which are required. 30 | > 31 | Sample Program.cs file: 32 | 33 | ```c# 34 | using Hydro.Configuration; 35 | 36 | var builder = WebApplication.CreateBuilder(args); 37 | 38 | builder.Services.AddRazorPages(); 39 | builder.Services.AddHydro(); // Hydro 40 | 41 | var app = builder.Build(); 42 | 43 | app.UseStaticFiles(); 44 | app.UseRouting(); 45 | app.MapRazorPages(); 46 | app.UseHydro(); // Hydro 47 | 48 | app.Run(); 49 | ``` 50 | 51 | In `_ViewImports.cshtml` add: 52 | ```razor 53 | @addTagHelper *, Hydro 54 | @addTagHelper *, {Your project assembly name} 55 | ```` 56 | 57 | In layout's `head` tag: 58 | ```html 59 | 60 | 61 | 62 | ``` 63 | 64 | > NOTE: Hydro provides Alpine.js v3.14.3 with extensions combined into one file (`~/hydro/alpine.js`) for convenience. If you don't want to rely on the scripts provided by Hydro, you can manually specify Alpine.js sources. Make sure to include Alpine.js core script and Morph plugin. 65 | 66 | Now you are ready to create you first [component](/features/components). -------------------------------------------------------------------------------- /docs/content/introduction/motivation.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Motivation 6 | 7 | Hydro is a library for ASP.NET Core MVC and Razor Pages that 8 | introduces stateful and interactive components via Razor views (cshtml), view components and tag helpers. 9 | As a result, developers can create applications with server-side rendering (SSR), 10 | and single-page application (SPA) feeling without writing JavaScript. 11 | 12 | The motivation for building Hydro was to make the web development in .NET 13 | simple, streamlined and enjoyable for both back-end and front-end, while keeping all the powerful features of the .NET ecosystem. 14 | The main goal was to use the well-known patterns and practices to let developers smoothly deliver new features. 15 | 16 | Hydro is a response for growing complexity of the front-end ecosystem. Thousands of packages 17 | needed to run a simple application feels like an overkill; communication between front-end and back-end introduces 18 | unnecessary problems to handle; front-end frameworks become more and more sophisticated, often because of issues in their foundation. 19 | 20 | ## Hydro features 21 | 22 | Hydro offers the following features: 23 | - server side rendering 24 | - navigation enhancements 25 | - component state persistence across requests 26 | - UI interactivity 27 | 28 | Above statements are achieved by using simple, yet powerful, and well-tested techniques like Razor views (cshtml), view components and AJAX calls (internally via Alpine.js). 29 | 30 | Let's take a look at a simplified sequence of events that happens in a Hydro application: 31 | - application renders pages and Hydro components on the first call using Razor view engine (in Razor Pages or MVC) 32 | - state of the components is serialized and stored in the DOM 33 | - components contain actions used for running business logic and changing the component state; those actions are run via HTTP requests and can be triggered by the browser events like click, submit, keydown, etc. 34 | - on each request to the Hydro component, application generates HTML for affected components and gracefully morph the DOM with the changes 35 | 36 | Such approach makes state management very easy, since all the components keep the state across requests using DOM on the client side as a temporary storage, so no matter at what point in time, the state will be always the same as it was on the last render. Any connectivity issues, like lost 37 | connections, have no impact here. 38 | 39 | ## Transport 40 | 41 | Some might argue that using HTTP requests as the base for interactivity 42 | might cause performance issues related to request overhead or to request waterfall. 43 | In practice, it's not the case for most scenarios, since Hydro is performing micro-requests not requiring much resources. 44 | They are also queued in a way to provide consistency in the state between the operations. In a situation of slow connection, the problem exists anyway in any other kind of application that has to synchronize front-end state with the back-end. 45 | 46 | As a side note: it's a good exercise to open some popular SPA application and turn on the network analysis to see how many 47 | requests they perform. 48 | 49 | ## The future 50 | 51 | Hydro's goal is to make web development smooth, 52 | to allow working in one .NET ecosystem, have one build and enjoy 53 | the simplicity of crafting component based software without writing JavaScript. 54 | 55 | To get started [click here](/introduction/getting-started). 56 | -------------------------------------------------------------------------------- /docs/content/introduction/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Overview 6 | 7 | **Hydro** is an extension to ASP.NET Core MVC and Razor Pages. It extends View Components to make them reactive and stateful with ability to communicate with each other without page reloads. As a result, you can create powerful components and make your application to feel like SPA with zero or minimal amount of the JavaScript code (depending on the needs) and without separate front-end build step. It also works well with existing ASP.NET Core applications. 8 | 9 | ## How it works 10 | 11 | Hydro utilizes the following technologies to make it all work: 12 | 13 | - **Razor views and view components (\*.cshtml)** 14 | Razor views form the backbone of Hydro's UI generation. They allow for a familiar, server-side rendering strategy that has been a foundation of .NET web development for many years. These *.cshtml files enable a seamless mix of HTML and C# code, allowing for robust and dynamic webpage generation. 15 | 16 | 17 | - **AJAX** 18 | AJAX calls are used to communicate between the client and the server, specifically to send the application state to the server, receive updates and responses back, and then store this state to be used in subsequent requests. This ensures that each request has the most up-to-date context and information. 19 | 20 | 21 | - **Alpine.js** 22 | Alpine.js stands as a base for requests execution and DOM swapping. But beyond that, Alpine.js also empowers users by providing a framework for adding rich, client-side interactivity to the standard HTML. So, not only does it serve Hydro's internal operations, but it also provides an expansion point for users to enhance their web applications with powerful, interactive experiences. 23 | -------------------------------------------------------------------------------- /docs/content/markdown-examples.md: -------------------------------------------------------------------------------- 1 | # Markdown Extension Examples 2 | 3 | This page demonstrates some of the built-in markdown extensions provided by VitePress. 4 | 5 | ## Syntax Highlighting 6 | 7 | VitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting: 8 | 9 | **Input** 10 | 11 | ```` 12 | ```js{4} 13 | export default { 14 | data () { 15 | return { 16 | msg: 'Highlighted!' 17 | } 18 | } 19 | } 20 | ``` 21 | ```` 22 | 23 | **Output** 24 | 25 | ```js{4} 26 | export default { 27 | data () { 28 | return { 29 | msg: 'Highlighted!' 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | ## Custom Containers 36 | 37 | **Input** 38 | 39 | ```md 40 | ::: info 41 | This is an info box. 42 | ::: 43 | 44 | ::: tip 45 | This is a tip. 46 | ::: 47 | 48 | ::: warning 49 | This is a warning. 50 | ::: 51 | 52 | ::: danger 53 | This is a dangerous warning. 54 | ::: 55 | 56 | ::: details 57 | This is a details block. 58 | ::: 59 | ``` 60 | 61 | **Output** 62 | 63 | ::: info 64 | This is an info box. 65 | ::: 66 | 67 | ::: tip 68 | This is a tip. 69 | ::: 70 | 71 | ::: warning 72 | This is a warning. 73 | ::: 74 | 75 | ::: danger 76 | This is a dangerous warning. 77 | ::: 78 | 79 | ::: details 80 | This is a details block. 81 | ::: 82 | 83 | ## More 84 | 85 | Check out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown). 86 | -------------------------------------------------------------------------------- /docs/content/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hydrostack/hydro/2dfdaa19868910d28387f508106d136288afd673/docs/content/public/logo.png -------------------------------------------------------------------------------- /docs/content/utilities/hydro-views.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Hydro views 6 | 7 | Hydro views are an extension built in into Hydro that let you create new kind of view components that can replace partials, editors and regular view components. 8 | 9 | Here is a basic example of a Hydro view called `Submit`: 10 | 11 | ```c# 12 | // Submit.cshtml.cs 13 | 14 | public class Submit : HydroView; 15 | ``` 16 | 17 | ```razor 18 | 19 | 20 | @model Submit 21 | 22 | 25 | ``` 26 | 27 | Now we can use our Hydro view in any other razor view: 28 | 29 | ```razor 30 | 31 | 32 | 33 | ``` 34 | 35 | ## Naming conventions 36 | 37 | There are 2 ways of naming Hydro views: 38 | 39 | 1. Automatic naming with dash-case notation: 40 | 41 | ```c# 42 | public class SubmitButton : HydroView; 43 | ``` 44 | 45 | Usage: `` 46 | 47 | 2. Manual naming with PascalCase using `nameof`: 48 | 49 | ```c# 50 | [HtmlTargetElement(nameof(SubmitButton))] 51 | public class SubmitButton : HydroView; 52 | ``` 53 | 54 | Usage: `` 55 | 56 | ## Parameters 57 | 58 | Hydro views use parameters to pass the data from a caller to the view. Example: 59 | 60 | ```c# 61 | // Alert.cshtml.cs 62 | 63 | public class Alert : HydroView 64 | { 65 | public string Message { get; set; } 66 | } 67 | ``` 68 | 69 | ```razor 70 | 71 | 72 | @model Alert 73 | 74 |
75 | @Model.Message 76 |
77 | ``` 78 | 79 | Now we can set a value on the `message` attribute that will be passed to our `Alert`: 80 | 81 | ```razor 82 | 83 | 84 | 85 | ``` 86 | 87 | Parameter names are converted to kebab-case when used as attributes on tags, so: 88 | - `Message` property becomes `message` attribute. 89 | - `StatusCode` property becomes `status-code` attribute. 90 | 91 | ## Passing handlers as parameters 92 | 93 | When you need to pass a handler to a Hydro view, you can use the `Expression` type: 94 | 95 | ```c# 96 | // FormButton.cshtml.cs 97 | 98 | public class FormButton : HydroView 99 | { 100 | public Expression Click { get; set; } 101 | } 102 | ``` 103 | 104 | ```razor 105 | 106 | 107 | @model FormButton 108 | 109 | 112 | ``` 113 | 114 | Usage: 115 | 116 | ```razor 117 | 118 | Save 119 | 120 | ``` 121 | 122 | ## Calling actions of a parent Hydro component 123 | 124 | You can call an action of a parent Hydro component from a Hydro view using the `Reference`. Example: 125 | 126 | Parent (Hydro component): 127 | 128 | ```c# 129 | public class Parent : HydroComponent 130 | { 131 | public string Value { get; set; } 132 | 133 | public void LoadText(string value) 134 | { 135 | Value = value; 136 | } 137 | } 138 | ``` 139 | 140 | ```razor 141 | 142 | 143 | @model Parent 144 | 145 | 146 | ``` 147 | 148 | Child (Hydro view): 149 | 150 | ```c# 151 | public class ChildView : HydroView; 152 | ``` 153 | 154 | ```razor 155 | 156 | 157 | @model ChildView 158 | 159 | @{ 160 | var parent = Model.Reference(); 161 | } 162 | 163 | 166 | ``` 167 | 168 | ## Dynamic attributes 169 | 170 | All attributes passed to the Hydro view by the caller are available in a view definition, even when they are not defined as properties. 171 | We can use this feature to pass optional html attributes to the view, for example: 172 | 173 | ```c# 174 | // Alert.cshtml.cs 175 | 176 | public class Alert : HydroView 177 | { 178 | public string Message { get; set; } 179 | } 180 | ``` 181 | 182 | ```razor 183 | 184 | 185 | @model Alert 186 | 187 |
188 | @Model.Message 189 |
190 | ``` 191 | 192 | Now we can set an optional attribute `class` that will be added to the final view: 193 | 194 | ```razor 195 | 196 | 197 | 198 | ``` 199 | 200 | ## Child content 201 | 202 | Hydro views support passing the child html content, so it can be used later when rendering the view. Example: 203 | 204 | ```c# 205 | // DisplayField.cshtml.cs 206 | 207 | public class DisplayField : HydroView 208 | { 209 | public string Title { get; set; }; 210 | } 211 | ``` 212 | 213 | ```razor 214 | 215 | 216 | @model DisplayField 217 | 218 |
219 |
220 | @Model.Title 221 |
222 | 223 |
224 | @Model.Slot() 225 |
226 |
227 | ``` 228 | 229 | Usage: 230 | 231 | ```razor 232 | 233 | 234 | 235 | 199 EUR 236 | 237 | ``` 238 | 239 | Remarks: 240 | - `Model.Slot()` renders the child content passed by the tag caller 241 | 242 | 243 | ## Slots 244 | 245 | Slots are placeholders for html content inside Hydro views that can be passed by the caller. Here is an example of a `Card` tag: 246 | 247 | ```c# 248 | // Card.cshtml.cs 249 | 250 | public class Card : HydroView; 251 | ``` 252 | 253 | ```razor 254 | 255 | 256 | @model Card 257 | 258 |
259 |
260 | @Model.Slot("header") 261 |
262 | 263 |
264 | @Model.Slot() 265 |
266 | 267 | 270 |
271 | ``` 272 | 273 | Usage: 274 | 275 | ```razor 276 | 277 | 278 | 279 | 280 | Laptop 281 | 282 | 283 | Information about the product 284 | 285 | 286 | Price: $199 287 | 288 | 289 | ``` 290 | 291 | Remarks: 292 | - `Model.Slot("header")` renders the content of passed through `` 293 | - `Model.Slot("footer")` renders the content of passed through `` 294 | - `Model.Slot()` renders the rest of the child content 295 | 296 | ## Differences between Hydro components and Hydro views 297 | 298 | **Hydro views:** 299 | - Used to render views. 300 | - Not stateful or interactive. 301 | - Replacement for partial views, editors or regular view components. 302 | - Rerendered on each request. 303 | 304 | **Hydro components:** 305 | - Used to render functional components. 306 | - Stateful and interactive. 307 | - Should be used when state is needed. 308 | - Rerendered only in specific situations, like action calls or events. 309 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "vitepress": "^1.0.0-rc.15" 4 | }, 5 | "type": "module", 6 | "scripts": { 7 | "docs:dev": "vitepress dev content", 8 | "docs:build": "vitepress build content", 9 | "docs:preview": "vitepress preview content" 10 | } 11 | } -------------------------------------------------------------------------------- /publish.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | del src\bin\Release\*.nupkg 3 | dotnet pack src -c Release 4 | nuget push src\bin\Release\*.nupkg -Source https://api.nuget.org/v3/index.json -------------------------------------------------------------------------------- /shared/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hydrostack/hydro/2dfdaa19868910d28387f508106d136288afd673/shared/logo.png -------------------------------------------------------------------------------- /shared/logo_80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hydrostack/hydro/2dfdaa19868910d28387f508106d136288afd673/shared/logo_80.png -------------------------------------------------------------------------------- /shared/logo_95.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hydrostack/hydro/2dfdaa19868910d28387f508106d136288afd673/shared/logo_95.png -------------------------------------------------------------------------------- /src/Cache.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Cached value provider 5 | /// 6 | /// Type of cached value 7 | public class Cache 8 | { 9 | private T _value; 10 | private readonly Func _valueFunc; 11 | 12 | /// 13 | /// Value 14 | /// 15 | public T Value => IsSet 16 | ? _value 17 | : _value = _valueFunc(); 18 | 19 | /// 20 | /// Is value set 21 | /// 22 | public bool IsSet { get; private set; } 23 | 24 | /// 25 | /// Instantiates Cache 26 | /// 27 | public Cache(Func func) 28 | { 29 | _valueFunc = func; 30 | } 31 | 32 | /// 33 | /// Reset value 34 | /// 35 | public void Reset() 36 | { 37 | IsSet = false; 38 | } 39 | } -------------------------------------------------------------------------------- /src/CacheKey.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | internal record CacheKey(string key, Delegate delegateKey); 4 | -------------------------------------------------------------------------------- /src/CacheLifetime.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Defines lifetime of a cached value 5 | /// 6 | public enum CacheLifetime 7 | { 8 | /// 9 | /// Value will be kept in cache during one request 10 | /// 11 | Request, 12 | 13 | /// 14 | /// Value will be kept in the current application's instance cache 15 | /// 16 | Application 17 | } -------------------------------------------------------------------------------- /src/ComponentResult.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.Net.Http.Headers; 3 | 4 | namespace Hydro; 5 | 6 | /// 7 | /// Component result 8 | /// 9 | public interface IComponentResult 10 | { 11 | /// 12 | /// Execute the result 13 | /// 14 | Task ExecuteAsync(HttpContext httpContext, HydroComponent component); 15 | } 16 | 17 | internal class ComponentResult : IComponentResult 18 | { 19 | private readonly IResult _result; 20 | private readonly ComponentResultType _type; 21 | 22 | internal ComponentResult(IResult result, ComponentResultType type) 23 | { 24 | _result = result; 25 | _type = type; 26 | } 27 | 28 | public async Task ExecuteAsync(HttpContext httpContext, HydroComponent component) 29 | { 30 | var response = httpContext.Response; 31 | 32 | response.Headers.TryAdd(HydroConsts.ResponseHeaders.SkipOutput, "True"); 33 | 34 | if (_type == ComponentResultType.File) 35 | { 36 | 37 | response.Headers.Append(HeaderNames.ContentDisposition, "inline"); 38 | } 39 | 40 | try 41 | { 42 | await _result.ExecuteAsync(httpContext); 43 | } 44 | catch 45 | { 46 | response.Headers.Remove(HeaderNames.ContentDisposition); 47 | throw; 48 | } 49 | 50 | if (response.Headers.Remove(HeaderNames.Location, out var location)) 51 | { 52 | response.StatusCode = StatusCodes.Status200OK; 53 | component.Redirect(location); 54 | } 55 | } 56 | } 57 | 58 | internal enum ComponentResultType 59 | { 60 | Empty, 61 | File, 62 | Challenge, 63 | SignIn, 64 | SignOut 65 | } -------------------------------------------------------------------------------- /src/ComponentResults.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Net.Http.Headers; 5 | 6 | namespace Hydro; 7 | 8 | /// 9 | /// Results for Hydro actions 10 | /// 11 | public static class ComponentResults 12 | { 13 | /// 14 | /// Create a ChallengeHttpResult 15 | /// 16 | public static IComponentResult Challenge( 17 | AuthenticationProperties properties = null, 18 | IList authenticationSchemes = null) 19 | => new ComponentResult(Results.Challenge(properties, authenticationSchemes), ComponentResultType.Challenge); 20 | 21 | /// 22 | /// Creates a SignInHttpResult 23 | /// 24 | public static IComponentResult SignIn( 25 | ClaimsPrincipal principal, 26 | AuthenticationProperties properties = null, 27 | string authenticationScheme = null) 28 | => new ComponentResult(Results.SignIn(principal, properties, authenticationScheme), ComponentResultType.SignIn); 29 | 30 | /// 31 | /// Creates a SignOutHttpResult 32 | /// 33 | public static IComponentResult SignOut(AuthenticationProperties properties = null, IList authenticationSchemes = null) 34 | => new ComponentResult(Results.SignOut(properties, authenticationSchemes), ComponentResultType.SignOut); 35 | 36 | /// 37 | /// Creates a FileContentHttpResult 38 | /// 39 | public static IComponentResult File( 40 | byte[] fileContents, 41 | string contentType = null, 42 | string fileDownloadName = null, 43 | bool enableRangeProcessing = false, 44 | DateTimeOffset? lastModified = null, 45 | EntityTagHeaderValue entityTag = null) 46 | => new ComponentResult(Results.File(fileContents, contentType, fileDownloadName, enableRangeProcessing, lastModified, entityTag), ComponentResultType.File); 47 | 48 | 49 | /// 50 | /// Creates a FileStreamHttpResult 51 | /// 52 | public static IComponentResult File( 53 | Stream fileStream, 54 | string contentType = null, 55 | string fileDownloadName = null, 56 | DateTimeOffset? lastModified = null, 57 | EntityTagHeaderValue entityTag = null, 58 | bool enableRangeProcessing = false) 59 | => new ComponentResult(Results.File(fileStream, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing), ComponentResultType.File); 60 | 61 | /// 62 | /// Returns either PhysicalFileHttpResult or VirtualFileHttpResult 63 | /// 64 | public static IComponentResult File( 65 | string path, 66 | string contentType = null, 67 | string fileDownloadName = null, 68 | DateTimeOffset? lastModified = null, 69 | EntityTagHeaderValue entityTag = null, 70 | bool enableRangeProcessing = false) 71 | => new ComponentResult(Results.File(path, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing), ComponentResultType.File); 72 | } -------------------------------------------------------------------------------- /src/Configuration/ApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.FileProviders; 5 | 6 | namespace Hydro.Configuration; 7 | 8 | /// 9 | /// Hydro extensions to IApplicationBuilder 10 | /// 11 | public static class ApplicationBuilderExtensions 12 | { 13 | /// 14 | /// Adds configuration for hydro 15 | /// 16 | /// The instance this method extends. 17 | /// Current environment 18 | public static IApplicationBuilder UseHydro(this IApplicationBuilder builder, IWebHostEnvironment environment = null) => 19 | builder.UseHydro(environment, Assembly.GetCallingAssembly()); 20 | 21 | /// 22 | /// Adds configuration for hydro 23 | /// 24 | /// The instance this method extends. 25 | /// Current environment 26 | /// Assembly to scan for the Hydro components 27 | /// 28 | public static IApplicationBuilder UseHydro(this IApplicationBuilder builder, IWebHostEnvironment environment, Assembly assembly) 29 | { 30 | builder.UseEndpoints(endpoints => 31 | { 32 | var types = assembly.GetTypes().Where(t => t.IsAssignableTo(typeof(HydroComponent))).ToList(); 33 | 34 | foreach (var type in types) 35 | { 36 | endpoints.MapHydroComponent(type); 37 | } 38 | }); 39 | 40 | environment ??= (IWebHostEnvironment)builder.ApplicationServices.GetService(typeof(IWebHostEnvironment))!; 41 | 42 | var existingProvider = environment.WebRootFileProvider; 43 | 44 | var scriptsFileProvider = new ScriptsFileProvider(typeof(ApplicationBuilderExtensions).Assembly); 45 | var compositeProvider = new CompositeFileProvider(existingProvider, scriptsFileProvider); 46 | environment.WebRootFileProvider = compositeProvider; 47 | 48 | return builder; 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/Configuration/HydroOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro.Configuration; 2 | 3 | /// 4 | /// Hydro options 5 | /// 6 | public class HydroOptions 7 | { 8 | private IEnumerable _valueMappers; 9 | 10 | internal Dictionary ValueMappersDictionary { get; set; } = new(); 11 | 12 | /// 13 | /// Indicates if antiforgery token should be exchanged during the communication 14 | /// 15 | public bool AntiforgeryTokenEnabled { get; set; } 16 | 17 | /// 18 | /// Performs mapping of each value that goes through binding mechanism in all the components 19 | /// 20 | public IEnumerable ValueMappers 21 | { 22 | get => _valueMappers; 23 | set 24 | { 25 | _valueMappers = value; 26 | 27 | if (value != null) 28 | { 29 | ValueMappersDictionary = value.ToDictionary(mapper => mapper.MappedType, mapper => mapper); 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/Configuration/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Hydro.Services; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.DependencyInjection.Extensions; 5 | 6 | namespace Hydro.Configuration; 7 | 8 | /// 9 | /// Hydro extensions to IServiceCollection 10 | /// 11 | public static class ServiceCollectionExtensions 12 | { 13 | /// 14 | /// Configures services required by 15 | /// 16 | /// 17 | /// 18 | /// 19 | public static IServiceCollection AddHydro(this IServiceCollection services, Action options = null) 20 | { 21 | var hydroOptions = new HydroOptions(); 22 | options?.Invoke(hydroOptions); 23 | services.AddSingleton(hydroOptions); 24 | services.TryAddSingleton(); 25 | 26 | return services; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/DummyView.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Rendering; 2 | using Microsoft.AspNetCore.Mvc.ViewEngines; 3 | 4 | namespace Hydro; 5 | 6 | internal class DummyView : IView 7 | { 8 | public string Path => string.Empty; 9 | public Task RenderAsync(ViewContext context) => Task.CompletedTask; 10 | } 11 | -------------------------------------------------------------------------------- /src/ExpressionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | 3 | namespace Hydro; 4 | 5 | internal static class ExpressionExtensions 6 | { 7 | private const string JsIndicationStart = "HYDRO_JS("; 8 | private const string JsIndicationEnd = ")HYDRO_JS"; 9 | 10 | public static (string Name, IDictionary Parameters)? GetNameAndParameters(this LambdaExpression expression) 11 | { 12 | if (expression is not { Body: MethodCallExpression methodCall }) 13 | { 14 | return null; 15 | } 16 | 17 | var name = methodCall.Method.Name; 18 | var paramInfos = methodCall.Method.GetParameters(); 19 | var arguments = methodCall.Arguments; 20 | 21 | if (arguments.Count == 0) 22 | { 23 | return (name, null); 24 | } 25 | 26 | var parameters = new Dictionary(); 27 | 28 | for (var i = 0; i < arguments.Count; i++) 29 | { 30 | var paramName = paramInfos[i].Name!; 31 | 32 | try 33 | { 34 | parameters[paramName] = EvaluateExpressionValue(arguments[i]); 35 | } 36 | catch(Exception exception) 37 | { 38 | throw new NotSupportedException($"Unsupported expression type in the Hydro action call: {expression.GetType().Name}, parameter: {paramName}. Try to use primitive value as a parameter.", exception); 39 | } 40 | } 41 | 42 | return (name, parameters); 43 | } 44 | 45 | internal static object EvaluateExpressionValue(Expression expression) 46 | { 47 | switch (expression) 48 | { 49 | case ConstantExpression constantExpression: 50 | return constantExpression.Value; 51 | 52 | case MemberExpression memberExpression: 53 | return CompileAndEvaluate(memberExpression); 54 | 55 | case MethodCallExpression callExpression 56 | when callExpression.Method.DeclaringType == typeof(Param) 57 | && callExpression.Method.Name == nameof(Param.JS) 58 | && callExpression.Arguments.Any() 59 | && callExpression.Arguments[0] is ConstantExpression constantExpression: 60 | 61 | var value = ReplaceJsQuotes(constantExpression.Value?.ToString() ?? string.Empty); 62 | return EncodeJsExpression(value); 63 | 64 | case MethodCallExpression callExpression 65 | when callExpression.Method.DeclaringType == typeof(Param) 66 | && callExpression.Method.Name == nameof(Param.JS) 67 | && callExpression.Arguments.Any() 68 | && callExpression.Arguments[0] is MemberExpression memberExpression: 69 | 70 | var expressionValue = EvaluateExpressionValue(memberExpression); 71 | var normalizedExpressionValue = ReplaceJsQuotes(expressionValue?.ToString() ?? string.Empty); 72 | return EncodeJsExpression(normalizedExpressionValue); 73 | 74 | default: 75 | return CompileAndEvaluate(expression); 76 | } 77 | } 78 | 79 | private static object CompileAndEvaluate(Expression expression) 80 | { 81 | var objectMember = Expression.Convert(expression, typeof(object)); 82 | var getterLambda = Expression.Lambda>(objectMember); 83 | var getter = getterLambda.Compile(); 84 | return getter(); 85 | } 86 | 87 | internal static string ReplaceJsQuotes(string value) => 88 | value 89 | .Replace("\"", """) 90 | .Replace("'", "'"); 91 | 92 | internal static string DecodeJsExpressionsInJson(string json) => 93 | json.Replace("\"" + JsIndicationStart, "") 94 | .Replace(JsIndicationEnd + "\"", ""); 95 | 96 | private static string EncodeJsExpression(object expression) => 97 | $"{JsIndicationStart}{expression}{JsIndicationEnd}"; 98 | } -------------------------------------------------------------------------------- /src/HtmlTemplate.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Html; 2 | using Microsoft.AspNetCore.Mvc.Rendering; 3 | 4 | namespace Hydro; 5 | 6 | /// 7 | /// Html template 8 | /// 9 | public delegate IHtmlContent HtmlTemplate(object obj); 10 | 11 | /// 12 | /// Extensions for HtmlTemplate 13 | /// 14 | public static class HtmlTemplateExtensions 15 | { 16 | /// 17 | /// Renders HtmlTemplate 18 | /// 19 | /// Instance of HtmlTemplate 20 | /// Html content 21 | public static IHtmlContent Render(this HtmlTemplate htmlTemplate) => 22 | htmlTemplate(null); 23 | 24 | /// 25 | /// Prepares template to be rendered 26 | /// 27 | /// Html helper 28 | /// Html template followed by @ sign 29 | public static IHtmlContent Template(this IHtmlHelper htmlHelper, Func content) => content(null); 30 | } 31 | -------------------------------------------------------------------------------- /src/Hydro.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net6.0;net7.0;net8.0 4 | enable 5 | Krzysztof Jeske 6 | $([System.DateTime]::op_Subtraction($([System.DateTime]::get_Now().get_Date()),$([System.DateTime]::new(2000,1,1))).get_TotalDays()) 7 | Hydro 8 | Copyright (c) Krzysztof Jeske 9 | https://github.com/hydrostack/hydro 10 | README.md 11 | https://github.com/hydrostack/hydro.git 12 | GIT 13 | ASP.NET Core Hydro Components 14 | false 15 | MIT 16 | $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb 17 | true 18 | Hydro 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | all 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | true 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/Hydro.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.32014.148 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hydro", "Hydro.csproj", "{C5C713BF-3F53-44C0-9BC2-42D106180FFE}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {C5C713BF-3F53-44C0-9BC2-42D106180FFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {C5C713BF-3F53-44C0-9BC2-42D106180FFE}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {C5C713BF-3F53-44C0-9BC2-42D106180FFE}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {C5C713BF-3F53-44C0-9BC2-42D106180FFE}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {8C47719A-0A7E-4B22-B385-57EBF406D882} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /src/HydroBind.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Event used to set property value on current component 5 | /// 6 | /// Property name 7 | /// Value to set 8 | public record HydroBind(string Name, string Value); 9 | -------------------------------------------------------------------------------- /src/HydroClientActions.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace Hydro; 4 | 5 | /// 6 | /// Actions that are evaluated on the client side 7 | /// 8 | public class HydroClientActions 9 | { 10 | private readonly HydroComponent _hydroComponent; 11 | 12 | internal HydroClientActions(HydroComponent hydroComponent) => 13 | _hydroComponent = hydroComponent; 14 | 15 | /// 16 | /// Execute JavaScript expression on client side 17 | /// 18 | /// JavaScript expression 19 | public void ExecuteJs([LanguageInjection(InjectedLanguage.JAVASCRIPT)] string jsExpression) => 20 | _hydroComponent.AddClientScript(jsExpression); 21 | 22 | /// 23 | /// Dispatch a Hydro event 24 | /// 25 | public void Dispatch(TEvent data, Scope scope, bool asynchronous) => 26 | _hydroComponent.Dispatch(data, scope, asynchronous); 27 | 28 | /// 29 | /// Dispatch a Hydro event 30 | /// 31 | public void Dispatch(TEvent data, Scope scope) => 32 | _hydroComponent.Dispatch(data, scope); 33 | 34 | /// 35 | /// Dispatch a Hydro event 36 | /// 37 | public void Dispatch(TEvent data) => 38 | _hydroComponent.Dispatch(data); 39 | 40 | /// 41 | /// Dispatch a Hydro event 42 | /// 43 | public void DispatchGlobal(TEvent data) => 44 | _hydroComponent.DispatchGlobal(data); 45 | 46 | /// 47 | /// Dispatch a Hydro event 48 | /// 49 | public void DispatchGlobal(TEvent data, string subject) => 50 | _hydroComponent.DispatchGlobal(data, subject: subject); 51 | 52 | /// 53 | /// Invoke JavaScript expression on client side 54 | /// 55 | /// JavaScript expression 56 | [Obsolete("Use ExecuteJs instead.")] 57 | public void Invoke([LanguageInjection(InjectedLanguage.JAVASCRIPT)] string jsExpression) => 58 | ExecuteJs(jsExpression); 59 | } -------------------------------------------------------------------------------- /src/HydroComponentEvent.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | internal class HydroComponentEvent 4 | { 5 | public string Name { get; init; } 6 | public string Subject { get; init; } 7 | public object Data { get; init; } 8 | public string Scope { get; set; } 9 | public string OperationId { get; set; } 10 | } -------------------------------------------------------------------------------- /src/HydroComponentsExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Mime; 2 | using System.Text.Encodings.Web; 3 | using Microsoft.AspNetCore.Antiforgery; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Html; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.AspNetCore.Routing; 9 | using Hydro.Configuration; 10 | using Hydro.Utils; 11 | using Microsoft.Extensions.Logging; 12 | using Newtonsoft.Json; 13 | 14 | namespace Hydro; 15 | 16 | internal static class HydroComponentsExtensions 17 | { 18 | private static readonly JsonSerializerSettings JsonSerializerSettings = new() 19 | { 20 | Converters = new JsonConverter[] { new Int32Converter() }.ToList() 21 | }; 22 | 23 | public static void MapHydroComponent(this IEndpointRouteBuilder app, Type componentType) 24 | { 25 | var componentName = componentType.Name; 26 | 27 | app.MapPost($"/hydro/{componentName}/{{method?}}", async ( 28 | [FromServices] IServiceProvider serviceProvider, 29 | [FromServices] IViewComponentHelper viewComponentHelper, 30 | [FromServices] HydroOptions hydroOptions, 31 | [FromServices] IAntiforgery antiforgery, 32 | [FromServices] ILogger logger, 33 | HttpContext httpContext, 34 | string method 35 | ) => 36 | { 37 | if (hydroOptions.AntiforgeryTokenEnabled) 38 | { 39 | try 40 | { 41 | await antiforgery.ValidateRequestAsync(httpContext); 42 | } 43 | catch (AntiforgeryValidationException exception) 44 | { 45 | logger.LogWarning(exception, "Antiforgery token not valid"); 46 | var requestToken = antiforgery.GetTokens(httpContext).RequestToken; 47 | httpContext.Response.Headers.Append(HydroConsts.ResponseHeaders.RefreshToken, requestToken); 48 | return Results.BadRequest(new { token = requestToken }); 49 | } 50 | } 51 | 52 | if (httpContext.IsHydro()) 53 | { 54 | await ExecuteRequestOperations(httpContext, method); 55 | } 56 | 57 | var htmlContent = await TagHelperRenderer.RenderTagHelper(componentType, httpContext); 58 | 59 | if (httpContext.Response.Headers.ContainsKey(HydroConsts.ResponseHeaders.SkipOutput)) 60 | { 61 | return HydroEmptyResult.Instance; 62 | } 63 | 64 | var content = await GetHtml(htmlContent); 65 | return Results.Content(content, MediaTypeNames.Text.Html); 66 | }); 67 | } 68 | 69 | private static async Task ExecuteRequestOperations(HttpContext context, string method) 70 | { 71 | if (!context.Request.HasFormContentType) 72 | { 73 | throw new InvalidOperationException("Hydro form doesn't contain form which is required"); 74 | } 75 | 76 | var hydroData = await context.Request.ReadFormAsync(); 77 | 78 | var formValues = hydroData 79 | .Where(f => !f.Key.StartsWith("__hydro")) 80 | .ToDictionary(f => f.Key, f => f.Value); 81 | 82 | var model = hydroData["__hydro_model"].First(); 83 | var type = hydroData["__hydro_type"].First(); 84 | var parameters = JsonConvert.DeserializeObject>(hydroData["__hydro_parameters"].FirstOrDefault("{}"), JsonSerializerSettings); 85 | var eventData = JsonConvert.DeserializeObject(hydroData["__hydro_event"].FirstOrDefault(string.Empty)); 86 | var componentIds = JsonConvert.DeserializeObject(hydroData["__hydro_componentIds"].FirstOrDefault("[]")); 87 | var form = new FormCollection(formValues, hydroData.Files); 88 | 89 | context.Items.Add(HydroConsts.ContextItems.RenderedComponentIds, componentIds); 90 | context.Items.Add(HydroConsts.ContextItems.BaseModel, model); 91 | context.Items.Add(HydroConsts.ContextItems.Parameters, parameters); 92 | 93 | if (eventData != null) 94 | { 95 | context.Items.Add(HydroConsts.ContextItems.EventName, eventData.Name); 96 | context.Items.Add(HydroConsts.ContextItems.EventData, eventData.Data); 97 | context.Items.Add(HydroConsts.ContextItems.EventSubject, eventData.Subject); 98 | } 99 | 100 | if (!string.IsNullOrWhiteSpace(method) && type != "event") 101 | { 102 | context.Items.Add(HydroConsts.ContextItems.MethodName, method); 103 | } 104 | 105 | if (form.Any() || form.Files.Any()) 106 | { 107 | context.Items.Add(HydroConsts.ContextItems.RequestForm, form); 108 | } 109 | } 110 | 111 | private static async Task GetHtml(IHtmlContent htmlContent) 112 | { 113 | await using var writer = new StringWriter(); 114 | htmlContent.WriteTo(writer, HtmlEncoder.Default); 115 | await writer.FlushAsync(); 116 | return writer.ToString(); 117 | } 118 | } -------------------------------------------------------------------------------- /src/HydroConsts.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | internal static class HydroConsts 4 | { 5 | public static class RequestHeaders 6 | { 7 | public const string Boosted = "Hydro-Boosted"; 8 | public const string Hydro = "Hydro-Request"; 9 | public const string OperationId = "Hydro-Operation-Id"; 10 | public const string Payload = "Hydro-Payload"; 11 | } 12 | 13 | public static class ResponseHeaders 14 | { 15 | public const string Trigger = "Hydro-Trigger"; 16 | public const string LocationTarget = "Hydro-Location-Target"; 17 | public const string LocationTitle = "Hydro-Location-Title"; 18 | public const string OperationId = "Hydro-Operation-Id"; 19 | public const string SkipOutput = "Hydro-Skip-Output"; 20 | public const string RefreshToken = "Refresh-Antiforgery-Token"; 21 | public const string Scripts = "Hydro-Js"; 22 | } 23 | 24 | public static class ContextItems 25 | { 26 | public const string RenderedComponentIds = "hydro-all-ids"; 27 | public const string EventName = "hydro-event"; 28 | public const string MethodName = "hydro-method"; 29 | public const string BaseModel = "hydro-base-model"; 30 | public const string RequestForm = "hydro-request-form"; 31 | public const string Parameters = "hydro-parameters"; 32 | public const string EventData = "hydro-event-model"; 33 | public const string EventSubject = "hydro-event-subject"; 34 | public const string IsRootRendered = "hydro-root-rendered"; 35 | } 36 | 37 | public static class Component 38 | { 39 | public const string ParentComponentId = "ParentId"; 40 | public const string EventMethodName = "event"; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/HydroEmptyResult.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Hydro; 4 | 5 | internal sealed class HydroEmptyResult : IResult 6 | { 7 | private HydroEmptyResult() 8 | { 9 | } 10 | 11 | public static HydroEmptyResult Instance { get; } = new(); 12 | public Task ExecuteAsync(HttpContext httpContext) => Task.CompletedTask; 13 | } 14 | -------------------------------------------------------------------------------- /src/HydroEventPayload.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | internal class HydroEventPayload 4 | { 5 | public string Name { get; set; } 6 | public string Subject { get; set; } 7 | public object Data { get; set; } 8 | public Scope Scope { get; set; } 9 | public string OperationId { get; set; } 10 | } -------------------------------------------------------------------------------- /src/HydroEventSubscription.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | internal class HydroEventSubscription 4 | { 5 | public string EventName { get; set; } 6 | public Func SubjectRetriever { get; set; } 7 | public Delegate Action { get; set; } 8 | } -------------------------------------------------------------------------------- /src/HydroHtmlExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Mime; 2 | using JetBrains.Annotations; 3 | using Microsoft.AspNetCore.Html; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.AspNetCore.Mvc.Rendering; 6 | 7 | namespace Hydro; 8 | 9 | /// 10 | public static class HydroHtmlExtensions 11 | { 12 | /// 13 | /// Renders hydro component 14 | /// 15 | public static async Task Hydro(this IHtmlHelper helper, object parameters = null, string key = null) where TComponent : HydroComponent 16 | { 17 | var arguments = parameters != null || key != null 18 | ? new { Params = parameters, Key = key } 19 | : null; 20 | 21 | return await TagHelperRenderer.RenderTagHelper( 22 | componentType: typeof(TComponent), 23 | httpContext: helper.ViewContext.HttpContext, 24 | parameters: PropertyExtractor.GetPropertiesFromObject(arguments) 25 | ); 26 | } 27 | 28 | /// 29 | /// Renders hydro component 30 | /// 31 | public static async Task Hydro(this IHtmlHelper helper, string hydroComponentName, object parameters = null, string key = null) 32 | { 33 | var tagHelper = TagHelperRenderer.FindTagHelperType(hydroComponentName, helper.ViewContext.HttpContext); 34 | 35 | var arguments = parameters != null || key != null 36 | ? new { Params = parameters, Key = key } 37 | : null; 38 | 39 | return await TagHelperRenderer.RenderTagHelper( 40 | componentType: tagHelper, 41 | httpContext: helper.ViewContext.HttpContext, 42 | parameters: PropertyExtractor.GetPropertiesFromObject(arguments) 43 | ); 44 | } 45 | } -------------------------------------------------------------------------------- /src/HydroHttpContextExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Hydro; 4 | 5 | /// 6 | /// Hydro extensions for HttpContext 7 | /// 8 | public static class HydroHttpContextExtensions 9 | { 10 | /// 11 | /// Indicates if the request is going through Hydro's pipeline 12 | /// 13 | /// HttpContext instance 14 | /// Return false for boosted requests 15 | public static bool IsHydro(this HttpContext httpContext, bool excludeBoosted = false) => 16 | httpContext.Request.Headers.ContainsKey(HydroConsts.RequestHeaders.Hydro) 17 | && (!excludeBoosted || !httpContext.IsHydroBoosted()); 18 | 19 | /// 20 | /// Indicates if the request is using Hydro's boost functionality 21 | /// 22 | /// HttpContext instance 23 | public static bool IsHydroBoosted(this HttpContext httpContext) => 24 | httpContext.Request.Headers.ContainsKey(HydroConsts.RequestHeaders.Boosted); 25 | } -------------------------------------------------------------------------------- /src/HydroHttpResponseExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.Extensions.Primitives; 3 | using Newtonsoft.Json; 4 | 5 | namespace Hydro; 6 | 7 | /// 8 | /// Hydro extensions for HttpResponse 9 | /// 10 | public static class HydroHttpResponseExtensions 11 | { 12 | /// 13 | /// Add a response header that instructs Hydro to redirect to a specific page with page reload 14 | /// 15 | /// HttpResponse instance 16 | /// URL to redirect to 17 | public static void HydroRedirect(this HttpResponse response, string url) 18 | { 19 | if (string.IsNullOrWhiteSpace(url)) 20 | { 21 | throw new ArgumentException("url is not provided", nameof(url)); 22 | } 23 | 24 | response.Headers.Append("Hydro-Redirect", new StringValues(url)); 25 | } 26 | 27 | /// 28 | /// Add a response header that instructs Hydro to redirect to a specific page without page reload 29 | /// 30 | /// HttpResponse instance 31 | /// URL to redirect to 32 | /// Object to pass to destination page 33 | public static void HydroLocation(this HttpResponse response, string url, object payload = null) 34 | { 35 | if (string.IsNullOrWhiteSpace(url)) 36 | { 37 | throw new ArgumentException("url is not provided", nameof(url)); 38 | } 39 | 40 | var data = new 41 | { 42 | path = url, 43 | target = "body", 44 | payload 45 | }; 46 | 47 | response.Headers.Append("Hydro-Location", new StringValues(JsonConvert.SerializeObject(data))); 48 | } 49 | } -------------------------------------------------------------------------------- /src/HydroModelMetadataProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; 3 | using Microsoft.Extensions.Options; 4 | 5 | namespace Hydro; 6 | 7 | internal class HydroModelMetadataProvider : DefaultModelMetadataProvider 8 | { 9 | public HydroModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider) : base(detailsProvider) 10 | { 11 | } 12 | 13 | public HydroModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IOptions optionsAccessor) : base(detailsProvider, optionsAccessor) 14 | { 15 | } 16 | 17 | protected override DefaultMetadataDetails[] CreatePropertyDetails(ModelMetadataIdentity key) => 18 | base.CreatePropertyDetails(key) 19 | .Where(d => d.Key.PropertyInfo?.DeclaringType != typeof(ViewComponent)) 20 | .ToArray(); 21 | } -------------------------------------------------------------------------------- /src/HydroPageExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Razor; 2 | 3 | namespace Hydro; 4 | 5 | /// 6 | public static class HydroPageExtensions 7 | { 8 | /// 9 | /// Specifies selector to use when replacing content of the page 10 | /// 11 | public static void HydroTarget(this IRazorPage page, string selector = $"#{HydroComponent.LocationTargetId}", string title = null) 12 | { 13 | page.ViewContext.HttpContext.Response.Headers.TryAdd(HydroConsts.ResponseHeaders.LocationTarget, selector); 14 | 15 | if (!string.IsNullOrEmpty(title)) 16 | { 17 | page.ViewContext.HttpContext.Response.Headers.TryAdd(HydroConsts.ResponseHeaders.LocationTitle, title); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/HydroPoll.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | internal record HydroPoll(string Action, TimeSpan Interval); 4 | -------------------------------------------------------------------------------- /src/HydroValueMapper.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// 5 | /// 6 | /// 7 | public class HydroValueMapper : IHydroValueMapper 8 | { 9 | private Func> MapperAsync { get; set; } 10 | private Func Mapper { get; set; } 11 | 12 | /// 13 | public Type MappedType => typeof(T); 14 | 15 | /// 16 | public HydroValueMapper(Func> mapperAsync) => MapperAsync = mapperAsync; 17 | 18 | /// 19 | public HydroValueMapper(Func mapper) => Mapper = mapper; 20 | 21 | /// 22 | public async Task Map(object value) => 23 | MapperAsync != null 24 | ? await MapperAsync((T)value) 25 | : Mapper((T)value); 26 | } 27 | 28 | /// 29 | public interface IHydroValueMapper 30 | { 31 | /// 32 | Task Map(object value); 33 | 34 | /// 35 | Type MappedType { get; } 36 | } -------------------------------------------------------------------------------- /src/HydroView.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Microsoft.AspNetCore.Html; 3 | using Microsoft.AspNetCore.Mvc.ModelBinding; 4 | using Microsoft.AspNetCore.Mvc.Rendering; 5 | using Microsoft.AspNetCore.Mvc.ViewEngines; 6 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 7 | using Microsoft.AspNetCore.Razor.TagHelpers; 8 | using Microsoft.Extensions.DependencyInjection; 9 | 10 | namespace Hydro; 11 | 12 | /// 13 | /// Abstraction for a Razor view 14 | /// 15 | public abstract class HydroView : TagHelper 16 | { 17 | /// 18 | [HtmlAttributeNotBound] 19 | [ViewContext] 20 | public ViewContext ViewContext { get; set; } 21 | 22 | /// 23 | private HtmlString _mainSlot; 24 | 25 | /// 26 | private readonly Dictionary _slots = new(); 27 | 28 | /// 29 | public Dictionary Attributes { get; private set; } 30 | 31 | /// 32 | public object Attribute(string name) => 33 | Attributes.GetValueOrDefault(name); 34 | 35 | /// 36 | /// Renders slot content 37 | /// 38 | /// Name of the slot or null when main slot 39 | public HtmlString Slot(string name = null) => 40 | name == null ? _mainSlot : _slots.GetValueOrDefault(name); 41 | 42 | /// 43 | /// Checks if given slot is defined 44 | /// 45 | /// Name of the slot 46 | public bool HasSlot(string name) => 47 | _slots.ContainsKey(name); 48 | 49 | /// 50 | public ModelStateDictionary ModelState => ViewContext.ViewData.ModelState; 51 | 52 | /// 53 | public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) 54 | { 55 | await UpdateSlots(context, output); 56 | 57 | ApplyObjectFromDictionary(this, Attributes); 58 | 59 | var html = await GetViewHtml(); 60 | 61 | output.TagName = null; 62 | output.Content.SetHtmlContent(html); 63 | } 64 | 65 | /// 66 | /// Used to reference other components in the event handlers 67 | /// 68 | public T Reference() => default; 69 | 70 | private async Task GetViewHtml() 71 | { 72 | var services = ViewContext.HttpContext.RequestServices; 73 | var compositeViewEngine = services.GetService(); 74 | var modelMetadataProvider = services.GetService(); 75 | 76 | var viewType = GetType(); 77 | 78 | var view = GetView(compositeViewEngine, viewType) 79 | ?? GetView(compositeViewEngine, viewType, path => path.Replace("TagHelper.cshtml", ".cshtml")); 80 | 81 | await using var writer = new StringWriter(); 82 | var viewDataDictionary = new ViewDataDictionary(modelMetadataProvider, ModelState) 83 | { 84 | Model = this 85 | }; 86 | 87 | var viewContext = new ViewContext(ViewContext, view, viewDataDictionary, writer); 88 | 89 | await view.RenderAsync(viewContext); 90 | await writer.FlushAsync(); 91 | return writer.ToString(); 92 | } 93 | 94 | private async Task UpdateSlots(TagHelperContext context, TagHelperOutput output) 95 | { 96 | var slotContext = new SlotContext(); 97 | context.Items.Add($"SlotContext{context.UniqueId}", slotContext); 98 | var childContent = await output.GetChildContentAsync(); 99 | 100 | _mainSlot = new HtmlString(childContent.GetContent()); 101 | 102 | foreach (var slot in slotContext.Items) 103 | { 104 | _slots.Add(slot.Key, slot.Value); 105 | } 106 | 107 | Attributes = context.AllAttributes.ToDictionary(a => a.Name, a => a.Value); 108 | } 109 | 110 | /// 111 | /// Get the view path based on the type 112 | /// 113 | private IView GetView(IViewEngine viewEngine, Type type, Func nameConverter = null) 114 | { 115 | var assemblyName = type.Assembly.GetName().Name; 116 | var path = $"{type.FullName!.Replace(assemblyName!, "~").Replace(".", "/")}.cshtml"; 117 | var adjustedPath = nameConverter != null ? nameConverter(path) : path; 118 | return viewEngine.GetView(null, adjustedPath, false).View; 119 | } 120 | 121 | private void ApplyObjectFromDictionary(T target, IDictionary source) 122 | { 123 | if (source == null || target == null) 124 | { 125 | return; 126 | } 127 | 128 | var targetType = target.GetType(); 129 | 130 | foreach (var sourceProperty in source) 131 | { 132 | var targetProperty = targetType.GetProperty(sourceProperty.Key, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); 133 | 134 | if (targetProperty == null || !targetProperty.CanWrite) 135 | { 136 | continue; 137 | } 138 | 139 | var value = sourceProperty.Value; 140 | 141 | if (value != null && !targetProperty.PropertyType.IsInstanceOfType(value)) 142 | { 143 | throw new InvalidCastException($"Type mismatch in {sourceProperty.Key} parameter."); 144 | } 145 | 146 | targetProperty.SetValue(target, value); 147 | } 148 | } 149 | } 150 | 151 | /// 152 | [Obsolete("Use HydroView type instead")] 153 | public abstract class HydroTagHelper : HydroView 154 | { 155 | } -------------------------------------------------------------------------------- /src/ICompositeValidationResult.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Hydro; 4 | 5 | /// 6 | /// Provides data of complex model validation 7 | /// 8 | public interface ICompositeValidationResult 9 | { 10 | /// 11 | /// Result of complex object validation 12 | /// 13 | IEnumerable Results { get; } 14 | } -------------------------------------------------------------------------------- /src/IHydroAuthorizationFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Hydro; 4 | 5 | /// 6 | /// A filter that confirms component authorization 7 | /// 8 | public interface IHydroAuthorizationFilter 9 | { 10 | /// 11 | /// Called early in the component pipeline to confirm request is authorized 12 | /// 13 | /// HttpContext 14 | /// Hydro component instance 15 | /// Indication if the the operation is authorized 16 | Task AuthorizeAsync(HttpContext httpContext, object component); 17 | } -------------------------------------------------------------------------------- /src/JsonSettings.cs: -------------------------------------------------------------------------------- 1 | using Hydro.Utils; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Serialization; 4 | 5 | namespace Hydro; 6 | 7 | internal static class JsonSettings 8 | { 9 | public static readonly JsonSerializerSettings SerializerSettings = new() 10 | { 11 | Converters = new JsonConverter[] { new Int32Converter() }.ToList(), 12 | NullValueHandling = NullValueHandling.Ignore, 13 | ContractResolver = new CamelCasePropertyNamesContractResolver(), 14 | }; 15 | } -------------------------------------------------------------------------------- /src/KeyBehavior.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Defines the component behavior when key changes 5 | /// 6 | public enum KeyBehavior 7 | { 8 | /// 9 | /// Replace the component's HTML 10 | /// 11 | Replace, 12 | 13 | /// 14 | /// Morph the component's HTML 15 | /// 16 | Morph 17 | } -------------------------------------------------------------------------------- /src/Param.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Used in Hydro action calls for passing JavaScript expressions as parameters 5 | /// 6 | public static class Param 7 | { 8 | /// 9 | /// Pass JavaScript expression as a parameter to Hydro action 10 | /// 11 | /// JavaScript expression 12 | public static T JS(string value) => default; 13 | 14 | /// 15 | /// Pass JavaScript expression as a parameter to Hydro action 16 | /// 17 | /// JavaScript expression 18 | public static string JS(string value) => default; 19 | } -------------------------------------------------------------------------------- /src/PersistentState.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.DataProtection; 2 | using System.IO.Compression; 3 | using System.Text; 4 | 5 | namespace Hydro; 6 | 7 | internal interface IPersistentState 8 | { 9 | string Compress(string value); 10 | string Decompress(string value); 11 | } 12 | 13 | internal class PersistentState : IPersistentState 14 | { 15 | private readonly IDataProtector _protector; 16 | 17 | public PersistentState(IDataProtectionProvider provider) 18 | { 19 | _protector = provider.CreateProtector(nameof(PersistentState)); 20 | } 21 | 22 | public string Compress(string value) 23 | { 24 | var inputBytes = Encoding.UTF8.GetBytes(value); 25 | using var outputStream = new MemoryStream(); 26 | using (var brotliStream = new BrotliStream(outputStream, CompressionMode.Compress)) 27 | { 28 | brotliStream.Write(inputBytes, 0, inputBytes.Length); 29 | } 30 | 31 | return Convert.ToBase64String(outputStream.ToArray()); 32 | } 33 | 34 | public string Decompress(string value) 35 | { 36 | try 37 | { 38 | using var memoryStream = new MemoryStream(Convert.FromBase64String(value)); 39 | using var outputStream = new MemoryStream(); 40 | using (var brotliStream = new BrotliStream(memoryStream, CompressionMode.Decompress)) 41 | { 42 | brotliStream.CopyTo(outputStream); 43 | } 44 | 45 | return Encoding.UTF8.GetString(outputStream.ToArray()); 46 | } 47 | catch (FormatException) 48 | { 49 | return _protector.Unprotect(value); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/PollAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Enable long polling for action decorated with this attribute 5 | /// 6 | [AttributeUsage(AttributeTargets.Method)] 7 | public class PollAttribute : Attribute 8 | { 9 | /// 10 | /// How often action will be called. In milliseconds. 11 | /// 12 | public int Interval { get; set; } = 3_000; 13 | } -------------------------------------------------------------------------------- /src/PropertyExtractor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Reflection; 3 | 4 | namespace Hydro; 5 | 6 | internal static class PropertyExtractor 7 | { 8 | private static readonly ConcurrentDictionary PropertiesCache = new(); 9 | 10 | public static Dictionary GetPropertiesFromObject(object source) => 11 | source == null 12 | ? new() 13 | : PropertiesCache.GetOrAdd(source.GetType(), type => type.GetProperties()) 14 | .ToDictionary(p => p.Name, p => p.GetValue(source)); 15 | } -------------------------------------------------------------------------------- /src/PropertyInjector.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Extensions.Primitives; 4 | using Newtonsoft.Json; 5 | using System.Collections; 6 | using System.Collections.Concurrent; 7 | using System.ComponentModel; 8 | using System.Reflection; 9 | using Microsoft.AspNetCore.Razor.TagHelpers; 10 | 11 | namespace Hydro; 12 | 13 | internal static class PropertyInjector 14 | { 15 | private static readonly ConcurrentDictionary CachedPropertyInfos = new(); 16 | private static readonly ConcurrentDictionary PropertyCache = new(); 17 | 18 | public static string SerializeDeclaredProperties(Type type, object instance) 19 | { 20 | var regularProperties = GetRegularProperties(type, instance); 21 | return JsonConvert.SerializeObject(regularProperties, HydroComponent.JsonSerializerSettings); 22 | } 23 | 24 | private static IDictionary GetRegularProperties(Type type, object instance) => 25 | GetPropertyInfos(type).ToDictionary(p => p.Name, p => p.GetValue(instance)); 26 | 27 | private static IEnumerable GetPropertyInfos(Type type) 28 | { 29 | if (CachedPropertyInfos.TryGetValue(type, out var properties)) 30 | { 31 | return properties; 32 | } 33 | 34 | var viewComponentType = typeof(TagHelper); 35 | var hydroComponentType = typeof(HydroComponent); 36 | 37 | var baseProps = new[] { "Key", "IsModelTouched", "TouchedProperties" }; 38 | 39 | var propertyInfos = type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) 40 | .Where(p => (baseProps.Contains(p.Name) && p.DeclaringType == hydroComponentType) 41 | || (p.DeclaringType != viewComponentType && p.DeclaringType != hydroComponentType 42 | && p.GetGetMethod()?.IsPublic == true 43 | && p.GetSetMethod()?.IsPublic == true 44 | && !p.GetCustomAttributes().Any()) 45 | ) 46 | .ToArray(); 47 | 48 | CachedPropertyInfos.TryAdd(type, propertyInfos); 49 | 50 | return propertyInfos; 51 | } 52 | 53 | public static void SetPropertyValue(object target, string propertyPath, object value) 54 | { 55 | if (target == null) 56 | { 57 | throw new ArgumentNullException(nameof(target)); 58 | } 59 | 60 | if (string.IsNullOrWhiteSpace(propertyPath)) 61 | { 62 | throw new ArgumentException("Property path cannot be empty.", nameof(propertyPath)); 63 | } 64 | 65 | var properties = propertyPath.Split('.'); 66 | var currentObject = target; 67 | 68 | for (var i = 0; i < properties.Length - 1; i++) 69 | { 70 | currentObject = GetObjectOrIndexedValue(currentObject, properties[i]); 71 | } 72 | 73 | if (currentObject == null) 74 | { 75 | return; 76 | } 77 | 78 | var propName = properties[^1]; 79 | 80 | if (string.IsNullOrWhiteSpace(propName)) 81 | { 82 | throw new InvalidOperationException("Wrong property path"); 83 | } 84 | 85 | if (propName.Contains('[')) 86 | { 87 | throw new NotSupportedException(); 88 | } 89 | 90 | var propertyInfo = currentObject.GetType().GetProperty(propName); 91 | if (propertyInfo == null) 92 | { 93 | return; 94 | } 95 | 96 | var converter = TypeDescriptor.GetConverter(propertyInfo.PropertyType); 97 | var convertedValue = value == null || value.GetType() == propertyInfo.PropertyType 98 | ? value 99 | : converter.ConvertFrom(value); 100 | 101 | propertyInfo.SetValue(currentObject, convertedValue); 102 | } 103 | 104 | public static (object Value, Action Setter, Type PropertyType)? GetPropertySetter(object target, string propertyPath, object value) 105 | { 106 | if (target == null) 107 | { 108 | throw new ArgumentNullException(nameof(target)); 109 | } 110 | 111 | if (string.IsNullOrWhiteSpace(propertyPath)) 112 | { 113 | throw new ArgumentException("Property path cannot be empty.", nameof(propertyPath)); 114 | } 115 | 116 | var properties = propertyPath.Split('.'); 117 | var currentObject = target; 118 | 119 | for (var i = 0; i < properties.Length - 1; i++) 120 | { 121 | currentObject = GetObjectOrIndexedValue(currentObject, properties[i]); 122 | } 123 | 124 | return SetValueOnObject(currentObject, properties[^1], value); 125 | } 126 | 127 | private static object GetObjectOrIndexedValue(object obj, string propName) 128 | { 129 | if (obj == null) 130 | { 131 | return null; 132 | } 133 | 134 | if (string.IsNullOrWhiteSpace(propName)) 135 | { 136 | throw new InvalidOperationException("Wrong property path"); 137 | } 138 | 139 | return propName.Contains('[') 140 | ? GetIndexedValue(obj, propName) 141 | : obj.GetType().GetProperty(propName)?.GetValue(obj); 142 | } 143 | 144 | private static object GetIndexedValue(object obj, string propName) 145 | { 146 | if (obj == null) 147 | { 148 | return null; 149 | } 150 | 151 | var (index, cleanedPropName) = GetIndexAndCleanedPropertyName(propName); 152 | var propertyInfo = obj.GetType().GetProperty(cleanedPropName); 153 | 154 | if (propertyInfo == null) 155 | { 156 | return null; 157 | } 158 | 159 | var value = propertyInfo.GetValue(obj); 160 | 161 | if (value == null) 162 | { 163 | return null; 164 | } 165 | 166 | if (propertyInfo.PropertyType.IsArray) 167 | { 168 | return value is Array array && index < array.Length 169 | ? array.GetValue(index) 170 | : throw new InvalidOperationException("Wrong value type"); 171 | } 172 | 173 | if (typeof(IList).IsAssignableFrom(propertyInfo.PropertyType)) 174 | { 175 | return value is IList list && index < list.Count 176 | ? list[index] 177 | : throw new InvalidOperationException("Wrong value type"); 178 | } 179 | 180 | throw new InvalidOperationException("Wrong indexer property"); 181 | } 182 | 183 | private static (int, string) GetIndexAndCleanedPropertyName(string propName) 184 | { 185 | var iteratorStart = propName.IndexOf('['); 186 | var iteratorEnd = propName.IndexOf(']'); 187 | var iteratorValue = propName.Substring(iteratorStart + 1, iteratorEnd - iteratorStart - 1); 188 | var cleanedPropName = propName[..iteratorStart]; 189 | return (Convert.ToInt32(iteratorValue), cleanedPropName); 190 | } 191 | 192 | private static (object, Action, Type)? SetValueOnObject(object obj, string propName, object valueToSet) 193 | { 194 | if (obj == null) 195 | { 196 | return null; 197 | } 198 | 199 | if (string.IsNullOrWhiteSpace(propName)) 200 | { 201 | throw new InvalidOperationException("Wrong property path"); 202 | } 203 | 204 | if (propName.Contains('[')) 205 | { 206 | return SetIndexedValue(obj, propName, valueToSet); 207 | } 208 | 209 | var propertyInfo = obj.GetType().GetProperty(propName); 210 | if (propertyInfo == null) 211 | { 212 | return null; 213 | } 214 | 215 | var convertedValue = ConvertValue(valueToSet, propertyInfo.PropertyType); 216 | propertyInfo.SetValue(obj, convertedValue); 217 | return (convertedValue, val => propertyInfo.SetValue(obj, val), propertyInfo.PropertyType); 218 | } 219 | 220 | private static (object, Action, Type)? SetIndexedValue(object obj, string propName, object valueToSet) 221 | { 222 | var (index, cleanedPropName) = GetIndexAndCleanedPropertyName(propName); 223 | var propertyInfo = obj.GetType().GetProperty(cleanedPropName); 224 | var convertedValue = ConvertValue(valueToSet, propertyInfo!.PropertyType); 225 | 226 | var value = propertyInfo.GetValue(obj); 227 | 228 | if (value == null) 229 | { 230 | throw new InvalidOperationException("Cannot set value to null"); 231 | } 232 | 233 | if (propertyInfo.PropertyType.IsArray) 234 | { 235 | if (value is not Array array) 236 | { 237 | throw new InvalidOperationException("Wrong type"); 238 | } 239 | 240 | return (convertedValue, val => array.SetValue(val, index), propertyInfo!.PropertyType); 241 | } 242 | 243 | if (typeof(IList).IsAssignableFrom(propertyInfo.PropertyType)) 244 | { 245 | if (value is not IList list) 246 | { 247 | throw new InvalidOperationException("Wrong type"); 248 | } 249 | 250 | return (convertedValue, val => list[index] = val, propertyInfo!.PropertyType); 251 | } 252 | 253 | throw new InvalidOperationException($"Indexed access for property '{cleanedPropName}' is not supported."); 254 | } 255 | 256 | private static object ConvertValue(object valueToConvert, Type destinationType) 257 | { 258 | if (valueToConvert is not StringValues stringValues) 259 | { 260 | return valueToConvert; 261 | } 262 | 263 | if (typeof(IFormFile).IsAssignableFrom(destinationType) && StringValues.IsNullOrEmpty(stringValues)) 264 | { 265 | return null; 266 | } 267 | 268 | if (typeof(IFormFile[]).IsAssignableFrom(destinationType) && StringValues.IsNullOrEmpty(stringValues)) 269 | { 270 | return Array.Empty(); 271 | } 272 | 273 | if (typeof(IEnumerable).IsAssignableFrom(destinationType) && StringValues.IsNullOrEmpty(stringValues)) 274 | { 275 | return new List(); 276 | } 277 | 278 | var converter = TypeDescriptor.GetConverter(destinationType!); 279 | 280 | if (!converter.CanConvertFrom(typeof(string))) 281 | { 282 | throw new InvalidOperationException($"Cannot convert StringValues to '{destinationType}'."); 283 | } 284 | 285 | if (!destinationType.IsArray || stringValues is { Count: <= 1 }) 286 | { 287 | try 288 | { 289 | return converter.ConvertFromString(valueToConvert.ToString()); 290 | } 291 | catch 292 | { 293 | return Activator.CreateInstance(destinationType); 294 | } 295 | } 296 | 297 | var elementType = destinationType.GetElementType(); 298 | var array = Array.CreateInstance(elementType, stringValues.Count); 299 | for (var i = 0; i < stringValues.Count; i++) 300 | { 301 | try 302 | { 303 | array.SetValue(converter.ConvertFromString(stringValues[i]), i); 304 | } 305 | catch 306 | { 307 | array.SetValue(Activator.CreateInstance(elementType), i); 308 | } 309 | } 310 | 311 | return array; 312 | } 313 | } -------------------------------------------------------------------------------- /src/Scope.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Scope of the event 5 | /// 6 | public enum Scope 7 | { 8 | /// 9 | ///Parent 10 | /// 11 | Parent, 12 | 13 | /// 14 | /// Global 15 | /// 16 | Global 17 | } -------------------------------------------------------------------------------- /src/Scripts/AlpineJs/alpinejs-LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright © 2019-2021 Caleb Porzio and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/ScriptsFileProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Microsoft.Extensions.FileProviders; 3 | using Microsoft.Extensions.Primitives; 4 | 5 | namespace Hydro; 6 | 7 | internal class ScriptsFileProvider : IFileProvider 8 | { 9 | private readonly EmbeddedFileProvider _embeddedFileProvider; 10 | 11 | public ScriptsFileProvider(Assembly assembly) => 12 | _embeddedFileProvider = new EmbeddedFileProvider(assembly); 13 | 14 | public IDirectoryContents GetDirectoryContents(string subpath) => 15 | _embeddedFileProvider.GetDirectoryContents(subpath); 16 | 17 | public IFileInfo GetFileInfo(string subpath) => 18 | _embeddedFileProvider.GetFileInfo(subpath switch 19 | { 20 | "/hydro.js" => "/Scripts.hydro.js", 21 | "/hydro/hydro.js" => "/Scripts.hydro.js", 22 | "/hydro/alpine.js" => "/Scripts.AlpineJs.alpinejs-combined.min.js", 23 | _ => subpath 24 | }); 25 | 26 | public IChangeToken Watch(string filter) => 27 | _embeddedFileProvider.Watch(filter); 28 | } -------------------------------------------------------------------------------- /src/SelectValue.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Globalization; 3 | 4 | namespace Hydro; 5 | 6 | /// 7 | /// Model for select components 8 | /// 9 | /// Key's type 10 | [TypeConverter(typeof(SelectValueConverter))] 11 | public class SelectValue : ISelectValue 12 | { 13 | /// 14 | public string Id { get; set; } 15 | 16 | /// 17 | public static implicit operator string(SelectValue value) => value?.Id; 18 | 19 | /// 20 | public static implicit operator SelectValue(string value) => value == null ? null : new() { Id = value }; 21 | 22 | /// 23 | public override string ToString() => Id; 24 | } 25 | 26 | /// 27 | public interface ISelectValue 28 | { 29 | /// 30 | /// Represents id/key of selection 31 | /// 32 | string Id { get; set; } 33 | } 34 | 35 | /// 36 | public class SelectValueConverter : TypeConverter 37 | { 38 | private readonly Type _destinationType; 39 | 40 | /// 41 | public SelectValueConverter(Type destinationType) 42 | { 43 | _destinationType = destinationType; 44 | } 45 | 46 | /// 47 | public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) => 48 | sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); 49 | 50 | /// 51 | public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) 52 | { 53 | if (value is string id) 54 | { 55 | var selectValue = (ISelectValue)Activator.CreateInstance(_destinationType)!; 56 | selectValue.Id = id; 57 | return selectValue; 58 | } 59 | 60 | return base.ConvertFrom(context, culture, value); 61 | } 62 | } -------------------------------------------------------------------------------- /src/Services/CookieStorage.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Newtonsoft.Json; 3 | 4 | namespace Hydro.Services; 5 | 6 | /// 7 | /// Provides a standard implementation for ICookieManager interface, allowing to store/read complex objects in cookies 8 | /// 9 | public class CookieStorage 10 | { 11 | private readonly HttpContext _httpContext; 12 | private readonly IPersistentState _persistentState; 13 | 14 | /// 15 | /// Returns a value type or a specific class stored in cookies 16 | /// 17 | public T Get(string key, bool encryption = false, T defaultValue = default) 18 | { 19 | try 20 | { 21 | _httpContext.Request.Cookies.TryGetValue(key, out var storage); 22 | 23 | if (storage != null) 24 | { 25 | var json = encryption 26 | ? _persistentState.Decompress(storage) 27 | : storage; 28 | 29 | return JsonConvert.DeserializeObject(json); 30 | } 31 | } 32 | catch 33 | { 34 | //ignored 35 | } 36 | 37 | return defaultValue; 38 | } 39 | 40 | /// 41 | /// Default expiration time for cookies 42 | /// 43 | public static TimeSpan DefaultExpirationTime = TimeSpan.FromDays(30); 44 | 45 | /// 46 | /// Customizable default JsonSerializerSettings used for complex objects 47 | /// 48 | public static JsonSerializerSettings JsonSettings = new() 49 | { 50 | ReferenceLoopHandling = ReferenceLoopHandling.Ignore 51 | }; 52 | 53 | internal CookieStorage(HttpContext httpContext, IPersistentState persistentState) 54 | { 55 | _httpContext = httpContext; 56 | _persistentState = persistentState; 57 | } 58 | 59 | /// 60 | /// Stores provided `value` in cookies 61 | /// 62 | public void Set(string key, T value, bool encryption = false, TimeSpan? expiration = null) 63 | { 64 | var options = new CookieOptions 65 | { 66 | MaxAge = expiration ?? DefaultExpirationTime, 67 | }; 68 | 69 | Set(key, value, encryption, options); 70 | } 71 | 72 | /// 73 | /// Stores in cookies a value type or a specific class with exact options to be used. Will also be encrypted if `secure` is enabled in options. 74 | /// 75 | public void Set(string key, T value, bool encryption, CookieOptions options) 76 | { 77 | var response = _httpContext.Response; 78 | 79 | if (value != null) 80 | { 81 | var serializedValue = JsonConvert.SerializeObject(value, JsonSettings); 82 | var finalValue = encryption 83 | ? _persistentState.Compress(serializedValue) 84 | : serializedValue; 85 | 86 | response.Cookies.Append(key, finalValue, options); 87 | } 88 | else 89 | { 90 | response.Cookies.Delete(key); 91 | } 92 | } 93 | 94 | /// 95 | /// Deletes a cookie record 96 | /// 97 | public void Delete(string key) 98 | { 99 | var response = _httpContext.Response; 100 | response.Cookies.Delete(key); 101 | } 102 | } -------------------------------------------------------------------------------- /src/SkipOutputAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | 4 | /// 5 | /// Skips generating the HTML output after executing the decorated Hydro action. 6 | /// Any changes to the state won't be persisted. 7 | /// Useful when action is performing only side effects that do not cause changes to the current component's HTML content. 8 | /// 9 | [AttributeUsage(AttributeTargets.Method)] 10 | public class SkipOutputAttribute : Attribute 11 | { 12 | } -------------------------------------------------------------------------------- /src/SlotContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Html; 2 | 3 | namespace Hydro; 4 | 5 | internal class SlotContext 6 | { 7 | public Dictionary Items { get; set; } = new(); 8 | } -------------------------------------------------------------------------------- /src/SlotTagHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Html; 2 | using Microsoft.AspNetCore.Razor.TagHelpers; 3 | 4 | namespace Hydro; 5 | 6 | /// 7 | /// Defines content for a view slot 8 | /// 9 | [HtmlTargetElement("slot", Attributes="name")] 10 | public sealed class SlotTagHelper : TagHelper 11 | { 12 | /// 13 | /// Slot name 14 | /// 15 | [HtmlAttributeName("name")] 16 | public string Name { get; set; } 17 | 18 | /// 19 | public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) 20 | { 21 | var slotContext = (SlotContext)context.Items.Values.LastOrDefault(i => i.GetType() == typeof(SlotContext)); 22 | 23 | if (slotContext == null) 24 | { 25 | throw new InvalidOperationException("Cannot use slot without hydro tag helper as a parent"); 26 | } 27 | 28 | var childTagHelperContent = await output.GetChildContentAsync(); 29 | var childContent = childTagHelperContent.GetContent(); 30 | 31 | slotContext.Items.TryAdd(Name, new HtmlString(childContent)); 32 | output.SuppressOutput(); 33 | } 34 | } -------------------------------------------------------------------------------- /src/TagHelperRenderer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Reflection; 3 | using Microsoft.AspNetCore.Html; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.Abstractions; 7 | using Microsoft.AspNetCore.Mvc.ApplicationParts; 8 | using Microsoft.AspNetCore.Mvc.ModelBinding; 9 | using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; 10 | using Microsoft.AspNetCore.Mvc.Rendering; 11 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 12 | using Microsoft.AspNetCore.Razor.TagHelpers; 13 | using Microsoft.AspNetCore.Routing; 14 | using Microsoft.Extensions.DependencyInjection; 15 | 16 | namespace Hydro; 17 | 18 | internal class TagHelperRenderer 19 | { 20 | private static readonly ConcurrentDictionary PropertyCache = new(); 21 | private static IList _tagHelpers; 22 | 23 | public static Type FindTagHelperType(string componentName, HttpContext httpContext) 24 | { 25 | if (_tagHelpers == null) 26 | { 27 | var applicationPartManager = httpContext.RequestServices.GetRequiredService(); 28 | 29 | var tagHelperFeature = new TagHelperFeature(); 30 | applicationPartManager.PopulateFeature(tagHelperFeature); 31 | _tagHelpers = tagHelperFeature.TagHelpers.ToList(); 32 | } 33 | 34 | return _tagHelpers.FirstOrDefault(t => t.Name == componentName) 35 | ?? throw new InvalidOperationException($"Hydro component {componentName} not found"); 36 | ; 37 | } 38 | 39 | public static async Task RenderTagHelper(Type componentType, HttpContext httpContext, IDictionary parameters = null) 40 | { 41 | var serviceProvider = httpContext.RequestServices; 42 | var componentViewContext = CreateViewContext(httpContext, serviceProvider); 43 | return await RenderTagHelper(componentType, componentViewContext, parameters); 44 | } 45 | 46 | public static async Task RenderTagHelper(Type componentType, ViewContext viewContext, IDictionary parameters = null) 47 | { 48 | var tagHelperContext = new TagHelperContext(new(), new Dictionary(), Guid.NewGuid().ToString("N")); 49 | 50 | var tagHelperOutput = new TagHelperOutput( 51 | tagName: null, 52 | attributes: new(parameters != null ? parameters.Select(kv => new TagHelperAttribute(kv.Key, kv.Value)) : Array.Empty()), 53 | getChildContentAsync: (_, _) => Task.FromResult(new DefaultTagHelperContent()) 54 | ); 55 | 56 | var tagHelper = (TagHelper)ActivatorUtilities.CreateInstance(viewContext.HttpContext.RequestServices, componentType); 57 | 58 | if (parameters != null) 59 | { 60 | ApplyParameters(tagHelper, componentType, parameters); 61 | } 62 | 63 | ((IViewContextAware)tagHelper).Contextualize(viewContext); 64 | await tagHelper.ProcessAsync(tagHelperContext, tagHelperOutput); 65 | return tagHelperOutput.Content; 66 | } 67 | 68 | private static void ApplyParameters(TagHelper tagHelper, Type componentType, IDictionary parameters) 69 | { 70 | foreach (var parameter in parameters) 71 | { 72 | var cacheKey = $"{componentType.FullName}.{parameter.Key}"; 73 | var propertyInfo = PropertyCache.GetOrAdd(cacheKey, _ => 74 | componentType.GetProperty(parameter.Key, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)); 75 | 76 | if (propertyInfo != null && propertyInfo.CanWrite) 77 | { 78 | propertyInfo.SetValue(tagHelper, parameter.Value); 79 | } 80 | } 81 | } 82 | 83 | private static ViewContext CreateViewContext(HttpContext httpContext, IServiceProvider serviceProvider) 84 | { 85 | var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); 86 | var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); 87 | var tempData = new TempDataDictionary(httpContext, serviceProvider.GetRequiredService()); 88 | return new ViewContext(actionContext, new DummyView(), viewData, tempData, TextWriter.Null, new HtmlHelperOptions()); 89 | } 90 | } -------------------------------------------------------------------------------- /src/TagHelpers/AntiforgeryMetaTagHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Web; 2 | using Microsoft.AspNetCore.Antiforgery; 3 | using Microsoft.AspNetCore.Html; 4 | using Microsoft.AspNetCore.Mvc.Rendering; 5 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 6 | using Microsoft.AspNetCore.Razor.TagHelpers; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Hydro.Configuration; 9 | using Newtonsoft.Json; 10 | 11 | namespace Hydro.TagHelpers; 12 | 13 | /// 14 | /// Provides Hydro options serialized to a meta tag 15 | /// 16 | [HtmlTargetElement("meta", Attributes = "[name=hydro-config]")] 17 | public sealed class HydroConfigMetaTagHelper : TagHelper 18 | { 19 | /// 20 | /// View context 21 | /// 22 | [HtmlAttributeNotBound] 23 | [ViewContext] 24 | public ViewContext ViewContext { get; set; } 25 | 26 | /// 27 | /// Processes the output 28 | /// 29 | public override void Process(TagHelperContext context, TagHelperOutput output) 30 | { 31 | var hydroOptions = ViewContext.HttpContext.RequestServices.GetService(); 32 | 33 | var config = JsonConvert.SerializeObject(GetConfig(hydroOptions)); 34 | 35 | output.Attributes.RemoveAll("content"); 36 | 37 | output.Attributes.Add(new TagHelperAttribute( 38 | "content", 39 | new HtmlString(config), 40 | HtmlAttributeValueStyle.SingleQuotes) 41 | ); 42 | } 43 | 44 | private object GetConfig(HydroOptions options) => new 45 | { 46 | Antiforgery = GetAntiforgeryConfig(options) 47 | }; 48 | 49 | private AntiforgeryConfig GetAntiforgeryConfig(HydroOptions options) 50 | { 51 | if (!options.AntiforgeryTokenEnabled) 52 | { 53 | return null; 54 | } 55 | 56 | var antiforgery = ViewContext.HttpContext.RequestServices.GetService(); 57 | 58 | return antiforgery?.GetAndStoreTokens(ViewContext.HttpContext) is { } tokens 59 | ? new AntiforgeryConfig(tokens) 60 | : null; 61 | } 62 | 63 | private class AntiforgeryConfig 64 | { 65 | public AntiforgeryConfig(AntiforgeryTokenSet antiforgery) 66 | { 67 | ArgumentNullException.ThrowIfNull(antiforgery); 68 | 69 | HeaderName = antiforgery.HeaderName; 70 | Token = HttpUtility.HtmlAttributeEncode(antiforgery.RequestToken)!; 71 | } 72 | 73 | public string HeaderName { get; set; } 74 | public string Token { get; set; } 75 | } 76 | } -------------------------------------------------------------------------------- /src/TagHelpers/HydroBindShorthandTagHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Rendering; 2 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 3 | using Microsoft.AspNetCore.Razor.TagHelpers; 4 | 5 | namespace Hydro.TagHelpers; 6 | 7 | /// 8 | /// Tag helper for binding 9 | /// 10 | [HtmlTargetElement("input", Attributes = $"{EventsAttributePrefix}*")] 11 | [HtmlTargetElement("select", Attributes = $"{EventsAttributePrefix}*")] 12 | [HtmlTargetElement("textarea", Attributes = $"{EventsAttributePrefix}*")] 13 | [HtmlTargetElement("input", Attributes = EventsAttribute)] 14 | [HtmlTargetElement("select", Attributes = EventsAttribute)] 15 | [HtmlTargetElement("textarea", Attributes = EventsAttribute)] 16 | public sealed class HydroBindShorthandTagHelper : TagHelper 17 | { 18 | private const string EventsAttributePrefix = "bind:"; 19 | private const string EventsAttribute = "bind"; 20 | 21 | private IDictionary _events; 22 | 23 | /// 24 | [HtmlAttributeName(DictionaryAttributePrefix = EventsAttributePrefix)] 25 | public IDictionary Events 26 | { 27 | get => _events ??= new Dictionary(StringComparer.OrdinalIgnoreCase); 28 | set => _events = value; 29 | } 30 | 31 | /// 32 | [HtmlAttributeName(EventsAttribute)] 33 | public bool DefaultEvent { get; set; } 34 | 35 | /// 36 | [HtmlAttributeNotBound] 37 | [ViewContext] 38 | public ViewContext ViewContext { get; set; } 39 | 40 | /// 41 | public override void Process(TagHelperContext context, TagHelperOutput output) 42 | { 43 | ArgumentNullException.ThrowIfNull(context); 44 | ArgumentNullException.ThrowIfNull(output); 45 | 46 | var modelType = ViewContext?.ViewData.ModelMetadata.ContainerType ?? ViewContext?.ViewData.Model?.GetType(); 47 | 48 | if (modelType == null || (_events == null && !DefaultEvent)) 49 | { 50 | return; 51 | } 52 | 53 | if (DefaultEvent) 54 | { 55 | output.Attributes.Add(new("x-hydro-bind:change")); 56 | } 57 | 58 | if (_events != null) 59 | { 60 | foreach (var eventItem in _events) 61 | { 62 | var eventDefinition = eventItem.Key; 63 | 64 | output.Attributes.Add(new($"x-hydro-bind:{eventDefinition}")); 65 | } 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /src/TagHelpers/HydroBindTagHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Rendering; 2 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 3 | using Microsoft.AspNetCore.Razor.TagHelpers; 4 | 5 | namespace Hydro.TagHelpers; 6 | 7 | /// 8 | /// Tag helper for binding 9 | /// 10 | [HtmlTargetElement("input", Attributes = $"{EventsAttributePrefix}*")] 11 | [HtmlTargetElement("select", Attributes = $"{EventsAttributePrefix}*")] 12 | [HtmlTargetElement("textarea", Attributes = $"{EventsAttributePrefix}*")] 13 | [HtmlTargetElement("input", Attributes = EventsAttribute)] 14 | [HtmlTargetElement("select", Attributes = EventsAttribute)] 15 | [HtmlTargetElement("textarea", Attributes = EventsAttribute)] 16 | public sealed class HydroBindTagHelper : TagHelper 17 | { 18 | private const string EventsAttributePrefix = "hydro-bind:"; 19 | private const string EventsAttribute = "hydro-bind"; 20 | 21 | private IDictionary _events; 22 | 23 | /// 24 | [HtmlAttributeName(DictionaryAttributePrefix = EventsAttributePrefix)] 25 | public IDictionary Events 26 | { 27 | get => _events ??= new Dictionary(StringComparer.OrdinalIgnoreCase); 28 | set => _events = value; 29 | } 30 | 31 | /// 32 | [HtmlAttributeName(EventsAttribute)] 33 | public bool DefaultEvent { get; set; } 34 | 35 | /// 36 | [HtmlAttributeNotBound] 37 | [ViewContext] 38 | public ViewContext ViewContext { get; set; } 39 | 40 | /// 41 | public override void Process(TagHelperContext context, TagHelperOutput output) 42 | { 43 | ArgumentNullException.ThrowIfNull(context); 44 | ArgumentNullException.ThrowIfNull(output); 45 | 46 | var modelType = ViewContext?.ViewData.ModelMetadata.ContainerType ?? ViewContext?.ViewData.Model?.GetType(); 47 | 48 | if (modelType == null || (_events == null && !DefaultEvent)) 49 | { 50 | return; 51 | } 52 | 53 | if (DefaultEvent) 54 | { 55 | output.Attributes.Add(new("x-hydro-bind:change")); 56 | } 57 | 58 | if (_events != null) 59 | { 60 | foreach (var eventItem in _events) 61 | { 62 | var eventDefinition = eventItem.Key; 63 | 64 | output.Attributes.Add(new($"x-hydro-bind:{eventDefinition}")); 65 | } 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /src/TagHelpers/HydroComponentTagHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Html; 2 | using Microsoft.AspNetCore.Mvc.Rendering; 3 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 4 | using Microsoft.AspNetCore.Razor.TagHelpers; 5 | 6 | namespace Hydro.TagHelpers; 7 | 8 | /// 9 | /// Provides a binding from the DOM element to the Hydro action 10 | /// 11 | [HtmlTargetElement("hydro", Attributes = NameAttribute, TagStructure = TagStructure.WithoutEndTag)] 12 | public sealed class HydroComponentTagHelper : TagHelper 13 | { 14 | private const string NameAttribute = "name"; 15 | private const string ParametersAttribute = "params"; 16 | private const string ParametersPrefix = "param-"; 17 | 18 | private Dictionary _parameters; 19 | 20 | /// 21 | /// View context 22 | /// 23 | [HtmlAttributeNotBound] 24 | [ViewContext] 25 | public ViewContext ViewContext { get; set; } 26 | 27 | /// 28 | /// Hydro component's action to execute 29 | /// 30 | [HtmlAttributeName(NameAttribute)] 31 | public string Name { get; set; } 32 | 33 | /// 34 | /// Key of the component. Use it to distinguish same Hydro components on the same view 35 | /// 36 | [HtmlAttributeName("key")] 37 | public string Key { get; set; } 38 | 39 | /// 40 | /// Parameters passed to the component 41 | /// 42 | [HtmlAttributeName(DictionaryAttributePrefix = ParametersPrefix)] 43 | public Dictionary ParametersDictionary 44 | { 45 | get => _parameters ??= new Dictionary(StringComparer.OrdinalIgnoreCase); 46 | set => _parameters = value; 47 | } 48 | 49 | /// 50 | /// Parameters passed to the component 51 | /// 52 | [HtmlAttributeName(ParametersAttribute)] 53 | public object Parameters { get; set; } 54 | 55 | /// 56 | /// Triggering event 57 | /// 58 | [HtmlAttributeName("hydro-event")] 59 | public string Event { get; set; } 60 | 61 | /// 62 | /// Delay of executing the action, in milliseconds 63 | /// 64 | [HtmlAttributeName("delay")] 65 | public int? Delay { get; set; } = 0; 66 | 67 | /// 68 | /// Component's HTML behavior when the key changes 69 | /// 70 | [HtmlAttributeName("key-behavior")] 71 | public KeyBehavior KeyBehavior { get; set; } = KeyBehavior.Replace; 72 | 73 | /// 74 | /// Processes the tag helper 75 | /// 76 | public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) 77 | { 78 | ArgumentNullException.ThrowIfNull(context); 79 | ArgumentNullException.ThrowIfNull(output); 80 | 81 | output.TagName = null; 82 | var componentHtml = await GetTagHelperHtml(); 83 | output.Content.SetHtmlContent(componentHtml); 84 | } 85 | 86 | private async Task GetTagHelperHtml() 87 | { 88 | var tagHelper = TagHelperRenderer.FindTagHelperType(Name, ViewContext.HttpContext); 89 | 90 | var parameters = (Parameters != null ? PropertyExtractor.GetPropertiesFromObject(Parameters) : _parameters ?? new()) 91 | .Append(new(nameof(Key), Key)) 92 | .Append(new(nameof(KeyBehavior), KeyBehavior)) 93 | .ToDictionary(p => p.Key, p => p.Value); 94 | 95 | return await TagHelperRenderer.RenderTagHelper(tagHelper, ViewContext, parameters); 96 | } 97 | } -------------------------------------------------------------------------------- /src/TagHelpers/HydroDispatchTagHelper.cs: -------------------------------------------------------------------------------- 1 | using Hydro.Utils; 2 | using Microsoft.AspNetCore.Html; 3 | using Microsoft.AspNetCore.Mvc.Rendering; 4 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 5 | using Microsoft.AspNetCore.Razor.TagHelpers; 6 | using Newtonsoft.Json; 7 | 8 | namespace Hydro.TagHelpers; 9 | 10 | /// 11 | /// Provides a binding from the DOM element to the Hydro action 12 | /// 13 | [HtmlTargetElement("*", Attributes = DispatchAttribute)] 14 | [Obsolete("Use hydro-on instead")] 15 | public sealed class HydroDispatchTagHelper : TagHelper 16 | { 17 | private const string DispatchAttribute = "hydro-dispatch"; 18 | private const string ScopeAttribute = "event-scope"; 19 | private const string TriggerAttribute = "event-trigger"; 20 | 21 | /// 22 | /// View context 23 | /// 24 | [HtmlAttributeNotBound] 25 | [ViewContext] 26 | public ViewContext ViewContext { get; set; } 27 | 28 | /// 29 | /// Triggering event 30 | /// 31 | [HtmlAttributeName(DispatchAttribute)] 32 | public object Data { get; set; } 33 | 34 | /// 35 | /// Bind event 36 | /// 37 | [HtmlAttributeName(ScopeAttribute)] 38 | public Scope Scope { get; set; } = Scope.Parent; 39 | 40 | /// 41 | /// Triggering event 42 | /// 43 | [HtmlAttributeName(TriggerAttribute)] 44 | public string BrowserTriggerEvent { get; set; } 45 | 46 | /// 47 | /// Processes the tag helper 48 | /// 49 | public override void Process(TagHelperContext context, TagHelperOutput output) 50 | { 51 | ArgumentNullException.ThrowIfNull(context); 52 | ArgumentNullException.ThrowIfNull(output); 53 | 54 | var data = new 55 | { 56 | name = GetFullTypeName(Data.GetType()), 57 | data = Base64.Serialize(Data), 58 | scope = Scope 59 | }; 60 | 61 | output.Attributes.Add(new( 62 | "x-hydro-dispatch", 63 | new HtmlString(JsonConvert.SerializeObject(data)), 64 | HtmlAttributeValueStyle.SingleQuotes) 65 | ); 66 | 67 | if (!string.IsNullOrWhiteSpace(BrowserTriggerEvent)) 68 | { 69 | output.Attributes.Add("hydro-event", BrowserTriggerEvent); 70 | } 71 | } 72 | 73 | private static string GetFullTypeName(Type type) => 74 | type.DeclaringType != null 75 | ? type.DeclaringType.Name + "+" + type.Name 76 | : type.Name; 77 | } 78 | -------------------------------------------------------------------------------- /src/TagHelpers/HydroLinkTagHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Razor.TagHelpers; 2 | 3 | namespace Hydro.TagHelpers; 4 | 5 | /// 6 | /// Provides a mechanism to load the target url in the background and witch the body content when ready 7 | /// 8 | [HtmlTargetElement("*", Attributes = TagAttribute)] 9 | public sealed class HydroLinkTagHelper : TagHelper 10 | { 11 | private const string TagAttribute = "hydro-link"; 12 | 13 | /// 14 | /// Attribute that triggers the boost behavior 15 | /// 16 | [HtmlAttributeName(TagAttribute)] 17 | public bool Link { get; set; } = true; 18 | 19 | /// 20 | /// Processes the tag helper 21 | /// 22 | public override void Process(TagHelperContext context, TagHelperOutput output) 23 | { 24 | ArgumentNullException.ThrowIfNull(context); 25 | ArgumentNullException.ThrowIfNull(output); 26 | 27 | output.Attributes.RemoveAll("hydro-link"); 28 | output.Attributes.Add(new("x-data")); 29 | output.Attributes.Add(new("x-hydro-link")); 30 | } 31 | } -------------------------------------------------------------------------------- /src/TagHelpers/HydroOnTagHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | using Hydro.Utils; 3 | using Microsoft.AspNetCore.Html; 4 | using Microsoft.AspNetCore.Mvc.Rendering; 5 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 6 | using Microsoft.AspNetCore.Razor.TagHelpers; 7 | using Newtonsoft.Json; 8 | using static Hydro.ExpressionExtensions; 9 | 10 | namespace Hydro.TagHelpers; 11 | 12 | /// 13 | /// Tag helper for event handlers 14 | /// 15 | [HtmlTargetElement("*", Attributes = $"{HandlersPrefix}*")] 16 | [HtmlTargetElement("*", Attributes = $"{HandlersPrefixShort}*")] 17 | public sealed class HydroOnTagHelper : TagHelper 18 | { 19 | private const string HandlersPrefix = "hydro-on:"; 20 | private const string HandlersPrefixShort = "on:"; 21 | 22 | private IDictionary _handlers; 23 | private IDictionary _handlersShort; 24 | 25 | /// 26 | [HtmlAttributeName(DictionaryAttributePrefix = HandlersPrefix)] 27 | public IDictionary Handlers 28 | { 29 | get => _handlers ??= new Dictionary(StringComparer.OrdinalIgnoreCase); 30 | set => _handlers = value; 31 | } 32 | 33 | /// 34 | [HtmlAttributeName(DictionaryAttributePrefix = HandlersPrefixShort)] 35 | public IDictionary HandlersShort 36 | { 37 | get => _handlersShort ??= new Dictionary(StringComparer.OrdinalIgnoreCase); 38 | set => _handlersShort = value; 39 | } 40 | 41 | /// 42 | /// Disable during execution 43 | /// 44 | [HtmlAttributeName("hydro-disable")] 45 | public bool Disable { get; set; } 46 | 47 | /// 48 | [HtmlAttributeNotBound] 49 | [ViewContext] 50 | public ViewContext ViewContext { get; set; } 51 | 52 | /// 53 | public override void Process(TagHelperContext context, TagHelperOutput output) 54 | { 55 | ArgumentNullException.ThrowIfNull(context); 56 | ArgumentNullException.ThrowIfNull(output); 57 | 58 | var modelType = ViewContext?.ViewData.ModelMetadata.ContainerType ?? ViewContext?.ViewData.Model?.GetType(); 59 | 60 | var handlers = _handlersShort ?? _handlers; 61 | 62 | if (modelType == null || handlers == null) 63 | { 64 | return; 65 | } 66 | 67 | foreach (var eventItem in handlers.Where(h => h.Value != null)) 68 | { 69 | if (eventItem.Value is not LambdaExpression actionExpression) 70 | { 71 | throw new InvalidOperationException($"Wrong event handler statement in component for {modelType.Namespace}"); 72 | } 73 | 74 | var jsExpression = GetJsExpression(actionExpression); 75 | 76 | if (jsExpression == null) 77 | { 78 | continue; 79 | } 80 | 81 | var eventDefinition = eventItem.Key; 82 | output.Attributes.RemoveAll(HandlersPrefix + eventDefinition); 83 | output.Attributes.Add(new TagHelperAttribute($"x-on:{eventDefinition}", new HtmlString(jsExpression), HtmlAttributeValueStyle.SingleQuotes)); 84 | 85 | if (Disable || new[] { "click", "submit" }.Any(e => e.StartsWith(e))) 86 | { 87 | output.Attributes.Add(new("data-loading-disable")); 88 | } 89 | } 90 | } 91 | 92 | private static string GetJsExpression(LambdaExpression expression) 93 | { 94 | if (expression is not { Body: MethodCallExpression methodCall }) 95 | { 96 | throw new InvalidOperationException("Hydro action should contain a method call."); 97 | } 98 | 99 | var methodDeclaringType = methodCall.Method.DeclaringType; 100 | 101 | if (methodDeclaringType == typeof(HydroClientActions)) 102 | { 103 | return GetClientActionExpression(expression); 104 | } 105 | 106 | return GetActionInvokeExpression(expression); 107 | } 108 | 109 | private static string GetClientActionExpression(LambdaExpression expression) 110 | { 111 | var methodCall = (MethodCallExpression)expression.Body; 112 | 113 | var methodName = methodCall.Method.Name; 114 | 115 | switch (methodName) 116 | { 117 | case nameof(HydroClientActions.ExecuteJs): 118 | case nameof(HydroClientActions.Invoke): 119 | var jsExpressionValue = EvaluateExpressionValue(methodCall.Arguments[0]); 120 | return ReplaceJsQuotes(jsExpressionValue?.ToString()); 121 | 122 | case nameof(HydroComponent.Dispatch): 123 | case nameof(HydroComponent.DispatchGlobal): 124 | return GetDispatchInvokeExpression(expression, methodName); 125 | 126 | default: 127 | return null; 128 | } 129 | } 130 | 131 | private static string GetDispatchInvokeExpression(LambdaExpression expression, string methodName) 132 | { 133 | var dispatchData = expression.GetNameAndParameters(); 134 | 135 | if (dispatchData == null) 136 | { 137 | return null; 138 | } 139 | 140 | var parameters = dispatchData.Value.Parameters; 141 | 142 | var data = parameters["data"]; 143 | var scope = methodName == nameof(HydroComponent.DispatchGlobal) 144 | ? Scope.Global 145 | : GetParam(parameters, "scope"); 146 | var subject = GetParam(parameters, "subject"); 147 | 148 | var invokeData = new 149 | { 150 | name = GetFullTypeName(data.GetType()), 151 | data = Base64.Serialize(data), 152 | scope = scope.ToString().ToLower(), 153 | subject = subject 154 | }; 155 | 156 | var invokeJson = JsonConvert.SerializeObject(invokeData, JsonSettings.SerializerSettings); 157 | var invokeJsObject = DecodeJsExpressionsInJson(invokeJson); 158 | 159 | return $"dispatch($event, {invokeJsObject})"; 160 | } 161 | 162 | private static T GetParam(IDictionary parameters, string name, T fallback = default) => 163 | (T)(parameters.TryGetValue(name, out var value) 164 | ? value is T ? value : default(T) 165 | : default(T) 166 | ); 167 | 168 | private static string GetFullTypeName(Type type) => 169 | type.DeclaringType != null 170 | ? type.DeclaringType.Name + "+" + type.Name 171 | : type.Name; 172 | 173 | private static string GetActionInvokeExpression(LambdaExpression expression) 174 | { 175 | var eventData = expression.GetNameAndParameters(); 176 | 177 | if (eventData == null) 178 | { 179 | return null; 180 | } 181 | 182 | var invokeJson = JsonConvert.SerializeObject(new 183 | { 184 | eventData.Value.Name, 185 | eventData.Value.Parameters 186 | }, JsonSettings.SerializerSettings); 187 | 188 | var invokeJsObject = DecodeJsExpressionsInJson(invokeJson); 189 | 190 | return $"invoke($event, {invokeJsObject})"; 191 | } 192 | } -------------------------------------------------------------------------------- /src/TransientAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Skips serialization of property marked by this attributes 5 | /// 6 | [AttributeUsage(AttributeTargets.Property)] 7 | public class TransientAttribute : Attribute 8 | { 9 | } -------------------------------------------------------------------------------- /src/UnhandledHydroError.cs: -------------------------------------------------------------------------------- 1 | namespace Hydro; 2 | 3 | /// 4 | /// Event called in case of unhandled exception in Hydro component 5 | /// 6 | /// Exception message 7 | /// Payload 8 | public record UnhandledHydroError(string Message, object Data); -------------------------------------------------------------------------------- /src/Utils/Base64Extensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Newtonsoft.Json; 3 | 4 | namespace Hydro.Utils; 5 | 6 | internal static class Base64 7 | { 8 | public static string Serialize(object input) 9 | { 10 | if (input == null) 11 | { 12 | return null; 13 | } 14 | 15 | var json = JsonConvert.SerializeObject(input, HydroComponent.JsonSerializerSettings); 16 | var bytes = Encoding.UTF8.GetBytes(json); 17 | return Convert.ToBase64String(bytes); 18 | } 19 | 20 | public static object Deserialize(string input, Type outputType) 21 | { 22 | if (string.IsNullOrWhiteSpace(input)) 23 | { 24 | return null; 25 | } 26 | 27 | var bytes = Convert.FromBase64String(input); 28 | var json = Encoding.UTF8.GetString(bytes); 29 | return JsonConvert.DeserializeObject(json, outputType); 30 | } 31 | } -------------------------------------------------------------------------------- /src/Utils/Int32Converter.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Hydro.Utils; 4 | 5 | internal class Int32Converter : JsonConverter 6 | { 7 | public override bool CanConvert(Type objectType) => 8 | objectType == typeof(int) || objectType == typeof(long) || objectType == typeof(object); 9 | 10 | public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) => 11 | reader.TokenType == JsonToken.Integer 12 | ? Convert.ToInt32(reader.Value) 13 | : serializer.Deserialize(reader); 14 | 15 | public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => 16 | throw new InvalidOperationException(); 17 | 18 | public override bool CanWrite => false; 19 | } -------------------------------------------------------------------------------- /src/Utils/PropertyPath.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace Hydro.Utils; 4 | 5 | /// 6 | /// Represents property value in a hierarchy 7 | /// 8 | public class PropertyPath 9 | { 10 | /// 11 | /// Property name 12 | /// 13 | public string Name { get; set; } 14 | 15 | /// 16 | /// Array index, if applicable 17 | /// 18 | public int? Index { get; set; } 19 | 20 | /// 21 | /// Nested properties, if applicable 22 | /// 23 | public PropertyPath Child { get; set; } 24 | 25 | /// 26 | /// Returns the index of the array element, if applicable 27 | /// 28 | /// 29 | public int GetIndex() => Index!.Value; 30 | 31 | internal static PropertyPath ExtractPropertyPath(string propertyPath) 32 | { 33 | var items = propertyPath.Split('.', 2); 34 | var match = Regex.Match(items[0], @"^([\w]+)(?:\[(\d+)+\])?$"); 35 | 36 | return new PropertyPath 37 | { 38 | Name = match.Groups[1].Value, 39 | Index = match.Groups[2].Value != string.Empty ? int.Parse(match.Groups[2].Value) : null, 40 | Child = items.Length > 1 ? ExtractPropertyPath(items[1]) : null 41 | }; 42 | } 43 | } -------------------------------------------------------------------------------- /src/Validation/ValidateCollectionAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace Hydro.Validation; 5 | 6 | /// 7 | /// Validate collection of objects that also contain inner validation 8 | /// 9 | [AttributeUsage(AttributeTargets.Property)] 10 | public class ValidateCollectionAttribute : ValidationAttribute 11 | { 12 | /// 13 | protected override ValidationResult IsValid(object value, ValidationContext validationContext) 14 | { 15 | if (value is not IEnumerable enumerable) 16 | { 17 | return ValidationResult.Success; 18 | } 19 | 20 | var index = 0; 21 | 22 | var returnResults = new List(); 23 | 24 | foreach (var item in enumerable) 25 | { 26 | var results = new List(); 27 | var context = new ValidationContext(item); 28 | Validator.TryValidateObject(item, context, results, true); 29 | 30 | if (results.Count != 0) 31 | { 32 | returnResults.AddRange(results.Select(result => new ValidationResult(result.ErrorMessage, result.MemberNames.Select(m => $"{validationContext.MemberName}[{index}].{m}").ToList()))); 33 | } 34 | 35 | index++; 36 | } 37 | 38 | if (returnResults.Count == 0) 39 | { 40 | return ValidationResult.Success; 41 | } 42 | 43 | var compositeResults = new CompositeValidationResult($"Validation for {validationContext.DisplayName} failed!", new[] { validationContext.DisplayName }); 44 | returnResults.ForEach(compositeResults.AddResult); 45 | return compositeResults; 46 | } 47 | } 48 | 49 | /// 50 | public class CompositeValidationResult : ValidationResult, ICompositeValidationResult 51 | { 52 | private readonly List _results = new(); 53 | 54 | /// 55 | public IEnumerable Results => _results; 56 | 57 | /// 58 | public CompositeValidationResult(string errorMessage) : base(errorMessage) { } 59 | 60 | /// 61 | public CompositeValidationResult(string errorMessage, IEnumerable memberNames) : base(errorMessage, memberNames) { } 62 | 63 | /// 64 | protected CompositeValidationResult(ValidationResult validationResult) : base(validationResult) { } 65 | 66 | internal void AddResult(ValidationResult validationResult) 67 | { 68 | _results.Add(validationResult); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Validation/ValidateObjectAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Hydro.Validation; 4 | 5 | /// 6 | /// Validate complex object that also contains inner validations 7 | /// 8 | public class ValidateObjectAttribute : ValidationAttribute 9 | { 10 | /// 11 | protected override ValidationResult IsValid(object value, ValidationContext validationContext) 12 | { 13 | var results = new List(); 14 | 15 | if (value != null) 16 | { 17 | var context = new ValidationContext(value); 18 | Validator.TryValidateObject(value, context, results, true); 19 | } 20 | 21 | if (results.Count == 0) 22 | { 23 | return ValidationResult.Success; 24 | } 25 | 26 | var compositeResults = new CompositeValidationResult($"Validation for {validationContext.DisplayName} failed!"); 27 | 28 | foreach (var result in results) 29 | { 30 | compositeResults.AddResult(new ValidationResult(result.ErrorMessage, result.MemberNames.Select(m => $"{validationContext.MemberName}.{m}").ToList())); 31 | } 32 | 33 | return compositeResults; 34 | } 35 | } -------------------------------------------------------------------------------- /src/ViewBag.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Dynamic; 3 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 4 | 5 | namespace Hydro; 6 | 7 | [DebuggerDisplay("Count = {ViewData.Count}")] 8 | [DebuggerTypeProxy(typeof(DynamicViewDataView))] 9 | internal sealed class ViewBag : DynamicObject 10 | { 11 | private readonly Func _viewDataFunc; 12 | 13 | public ViewBag(Func viewDataFunc) => 14 | _viewDataFunc = viewDataFunc; 15 | 16 | private ViewDataDictionary ViewData => 17 | _viewDataFunc() ?? throw new InvalidOperationException("Invalid view data"); 18 | 19 | public override IEnumerable GetDynamicMemberNames() => 20 | ViewData.Keys; 21 | 22 | public override bool TryGetMember(GetMemberBinder binder, out object result) 23 | { 24 | result = ViewData[binder.Name]; 25 | return true; 26 | } 27 | 28 | public override bool TrySetMember(SetMemberBinder binder, object value) 29 | { 30 | ViewData[binder.Name] = value; 31 | return true; 32 | } 33 | 34 | private sealed class DynamicViewDataView 35 | { 36 | private readonly ViewDataDictionary _viewData; 37 | 38 | public DynamicViewDataView(ViewBag dictionary) 39 | { 40 | _viewData = dictionary.ViewData; 41 | } 42 | 43 | [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] 44 | public KeyValuePair[] Items => _viewData.ToArray(); 45 | } 46 | } -------------------------------------------------------------------------------- /src/ViewComponentExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Hydro; 4 | 5 | internal static class ViewComponentExtensions 6 | { 7 | public static IViewComponentResult DefaultView(this ViewComponent component, TModel model) => 8 | component.View(component.GetDefaultViewPath(), model); 9 | 10 | public static string GetDefaultViewPath(this ViewComponent component) 11 | { 12 | var type = component.GetType(); 13 | var assemblyName = type.Assembly.GetName().Name; 14 | 15 | return $"{type.FullName.Replace(assemblyName, "~").Replace(".", "/")}.cshtml"; 16 | } 17 | } 18 | --------------------------------------------------------------------------------