├── .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 | 
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 |
84 | Add
85 |
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 |
34 | Add
35 |
36 |
37 | ```
38 |
39 | ```razor
40 |
41 |
42 | @model Counter
43 |
44 | Count: @Model.Count
45 |
46 | Add
47 |
48 |
49 | ```
50 |
51 | Alternatively, you can use the `hydro-on` tag helper:
52 |
53 | ```razor
54 |
55 | Add
56 |
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 |
71 |
72 |
73 |
74 |
75 | ```
76 |
77 | ## Parameters
78 |
79 | If your action contains parameters, you can pass them in a regular way.
80 |
81 | Example:
82 |
83 | ```c#
84 | // Counter.cshtml.cs
85 |
86 | public class Counter : HydroComponent
87 | {
88 | public int Count { get; set; }
89 |
90 | public void Set(int newValue)
91 | {
92 | Count = newValue;
93 | }
94 | }
95 | ```
96 |
97 | ```razor
98 |
99 |
100 | @model Counter
101 |
102 | Count: @Model.Count
103 |
104 | Set to 20
105 |
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 |
195 | Update content
196 |
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 |
39 | Add
40 |
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 |
95 | Add
96 |
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 |
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 | Add
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 |
63 | Click me
64 |
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 | Customer name
210 |
211 |
212 |
213 | @foreach (var customer in await Model.Customers.Value)
214 | {
215 |
216 | @customer.Name
217 |
218 | }
219 |
220 |
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 | Submit ...
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 |
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 |
117 | Add
118 |
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 |
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 |
23 | Save
24 |
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 |
110 | @Model.Slot()
111 |
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 |
164 | Set text
165 |
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 |
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 |
--------------------------------------------------------------------------------