diff --git a/src/ExpressionExtensions.cs b/src/ExpressionExtensions.cs index d164067..f88cb26 100644 --- a/src/ExpressionExtensions.cs +++ b/src/ExpressionExtensions.cs @@ -13,7 +13,7 @@ public static (string Name, IDictionary Parameters)? GetNameAndP { return null; } - + var name = methodCall.Method.Name; var paramInfos = methodCall.Method.GetParameters(); var arguments = methodCall.Arguments; @@ -34,7 +34,7 @@ public static (string Name, IDictionary Parameters)? GetNameAndP return (name, parameters); } - private static object EvaluateExpressionValue(Expression expression) + internal static object EvaluateExpressionValue(Expression expression) { switch (expression) { @@ -53,17 +53,24 @@ private static object EvaluateExpressionValue(Expression expression) && callExpression.Arguments.Any() && callExpression.Arguments[0] is ConstantExpression constantExpression: - return EncodeJsExpression(constantExpression.Value); + var value = ReplaceJsQuotes(constantExpression.Value?.ToString() ?? string.Empty); + + return EncodeJsExpression(value); default: throw new NotSupportedException("Unsupported expression type: " + expression.GetType().Name); } } - private static string EncodeJsExpression(object expression) => - $"{JsIndicationStart}{expression}{JsIndicationEnd}"; - + internal static string ReplaceJsQuotes(string value) => + value + .Replace("\"", """) + .Replace("'", "'"); + internal static string DecodeJsExpressionsInJson(string json) => json.Replace("\"" + JsIndicationStart, "") .Replace(JsIndicationEnd + "\"", ""); + + private static string EncodeJsExpression(object expression) => + $"{JsIndicationStart}{expression}{JsIndicationEnd}"; } \ No newline at end of file diff --git a/src/HydroClientActions.cs b/src/HydroClientActions.cs new file mode 100644 index 0000000..0bd31bc --- /dev/null +++ b/src/HydroClientActions.cs @@ -0,0 +1,15 @@ +namespace Hydro; + +/// +/// Actions that are evaluated on the client side +/// +public class HydroClientActions +{ + /// + /// Invoke JavaScript expression on client side + /// + /// JavaScript expression + public void Invoke(string jsExpression) + { + } +} \ No newline at end of file diff --git a/src/HydroComponent.cs b/src/HydroComponent.cs index 3e02c94..863988b 100644 --- a/src/HydroComponent.cs +++ b/src/HydroComponent.cs @@ -63,7 +63,7 @@ public abstract class HydroComponent : ViewComponent /// Determines if the whole model was accessed already /// public bool IsModelTouched { get; set; } - + /// /// Determines if the current execution is related to the component mounting /// @@ -81,12 +81,12 @@ public HydroComponent() private void ConfigurePolls() { var componentType = GetType(); - + if (Polls.ContainsKey(componentType)) { return; } - + var methods = componentType .GetMethods(BindingFlags.Public | BindingFlags.Instance) .Where(m => m.DeclaringType != typeof(HydroComponent)) @@ -99,12 +99,12 @@ private void ConfigurePolls() { throw new InvalidOperationException("Poll can be defined only on custom actions"); } - + if (methods.Any(p => p.ParametersCount != 0)) { throw new InvalidOperationException("Poll can be defined only on actions without parameters"); } - + if (methods.Any(p => p.Attribute.Interval <= 0)) { throw new InvalidOperationException("Polling's interval is invalid"); @@ -113,7 +113,7 @@ private void ConfigurePolls() var polls = methods .Select(p => new HydroPoll(p.Method.Name, TimeSpan.FromMilliseconds(p.Attribute.Interval))) .ToList(); - + Polls.TryAdd(componentType, polls); } @@ -212,6 +212,11 @@ public void Dispatch(string name, TEvent data, Scope scope = Scope.Paren }); } + /// + /// Provides actions that can be executed on client side + /// + public HydroClientActions Client { get; } = new(); + /// /// Triggered once the component is mounted /// @@ -236,7 +241,7 @@ public virtual Task RenderAsync() Render(); return Task.CompletedTask; } - + /// /// Triggered before each render /// @@ -335,7 +340,7 @@ private async Task RenderOnlineRootComponent(IPersistentState persistent ? await GenerateComponentHtml(componentId, persistentState) : string.Empty; } - + private async Task RenderOnlineNestedComponent(IPersistentState persistentState) { var componentId = GenerateComponentId(Key); @@ -391,11 +396,11 @@ private async Task GenerateComponentHtml(string componentId, IPersistent { var previousParentComponentId = HttpContext.Items[HydroConsts.Component.ParentComponentId]; HttpContext.Items[HydroConsts.Component.ParentComponentId] = componentId; - + var componentHtmlDocument = await GetComponentHtml(); - + HttpContext.Items[HydroConsts.Component.ParentComponentId] = previousParentComponentId; - + var root = componentHtmlDocument.DocumentNode; if (root.ChildNodes.Count(n => n.NodeType == HtmlNodeType.Element) != 1) @@ -418,7 +423,7 @@ private async Task GenerateComponentHtml(string componentId, IPersistent rootElement.AppendChild(GetPollScript(componentHtmlDocument, polls[i], i)); } } - + rootElement.AppendChild(GetModelScript(componentHtmlDocument, componentId, persistentState)); foreach (var subscription in _subscriptions) @@ -450,7 +455,7 @@ private async Task BindModel(IFormCollection formCollection) { var setter = PropertyInjector.GetPropertySetter(this, pair.Key, pair.Value); var propertyPath = PropertyPath.ExtractPropertyPath(pair.Key); - + if (setter != null) { var value = _options.ValueMappersDictionary.TryGetValue(setter.Value.Value.GetType(), out var mapper) @@ -526,7 +531,7 @@ private HtmlNode GetEventSubscriptionScript(HtmlDocument document, HydroEventSub scriptNode.SetAttributeValue("x-on-hydro-event", JsonConvert.SerializeObject(eventData)); return scriptNode; } - + private HtmlNode GetPollScript(HtmlDocument document, HydroPoll poll, int index) { var scriptNode = document.CreateElement("script"); @@ -566,7 +571,7 @@ private async Task TriggerMethod() var methodInfos = GetType() .GetMethods(BindingFlags.Public | BindingFlags.Instance); - var requestParameters = GetParameters(); + var requestParameters = (IDictionary)HttpContext.Items[HydroConsts.ContextItems.Parameters]; var methodInfo = methodInfos.FirstOrDefault(m => string.Equals(m.Name, methodValue, StringComparison.OrdinalIgnoreCase) @@ -608,7 +613,7 @@ private async Task TriggerMethod() { return null; } - + var sourceType = requestParameter.GetType(); if (p.ParameterType == sourceType) @@ -636,11 +641,6 @@ private async Task TriggerMethod() } } - private IDictionary GetParameters() => - HttpContext.Request.Headers.TryGetValue(HydroConsts.RequestHeaders.Parameters, out var parameters) - ? JsonConvert.DeserializeObject>(parameters, JsonSerializerSettings) - : new Dictionary(); - /// /// Get the payload transferred from previous page's component /// @@ -669,9 +669,7 @@ private async Task TriggerEvent() var methodInfo = subscription.Action.Method; var parameters = methodInfo.GetParameters(); var parameterType = parameters.First().ParameterType; - var model = HttpContext.Items.TryGetValue(HydroConsts.ContextItems.EventData, out var eventModel) - ? JsonConvert.DeserializeObject((string)eventModel!, parameterType) - : null; + var model = JsonConvert.DeserializeObject((string)HttpContext.Items[HydroConsts.ContextItems.EventData], parameterType); var operationId = HttpContext.Request.Headers.TryGetValue(HydroConsts.RequestHeaders.OperationId, out var incomingOperationId) ? incomingOperationId.First() @@ -753,7 +751,7 @@ private async Task GetComponentHtml() { using var stream = new MemoryStream(); await using var writer = new StreamWriter(stream); - + var previousWriter = ViewComponentContext.ViewContext.Writer; ViewComponentContext.ViewContext.Writer = writer; ViewComponentContext.ViewContext.CheckBoxHiddenInputRenderMode = CheckBoxHiddenInputRenderMode.None; @@ -871,7 +869,7 @@ public bool Validate() private bool ValidateTouched() { ModelState.Clear(); - + var context = new ValidationContext(this, serviceProvider: null, items: null); var validationResults = new List(); Validator.TryValidateObject(this, context, validationResults, true); diff --git a/src/HydroComponentsExtensions.cs b/src/HydroComponentsExtensions.cs index f25fbd9..1f27d7c 100644 --- a/src/HydroComponentsExtensions.cs +++ b/src/HydroComponentsExtensions.cs @@ -59,63 +59,49 @@ string method var htmlContent = await viewComponentHelper.InvokeAsync(componentType); var content = await GetHtml(htmlContent); - + return Results.Content(content, MediaTypeNames.Text.Html); }); } private static async Task ExecuteRequestOperations(HttpContext context, string method) { - var requestModel = context.Request.Headers[HydroConsts.RequestHeaders.Model]; - var renderedComponentIDs = context.Request.Headers[HydroConsts.RequestHeaders.RenderedComponentIds]; - context.Items.Add(HydroConsts.ContextItems.RenderedComponentIds, JsonConvert.DeserializeObject(renderedComponentIDs)); - - if (!string.IsNullOrWhiteSpace(requestModel)) + if (!context.Request.HasFormContentType) { - context.Items.Add(HydroConsts.ContextItems.BaseModel, requestModel.ToString()); + throw new InvalidOperationException("Hydro form doesn't contain form which is required"); } - if (!string.IsNullOrEmpty(method)) - { - if (method == HydroConsts.Component.EventMethodName) - { - var eventName = context.Request.Headers[HydroConsts.RequestHeaders.EventName]; + var hydroData = await context.Request.ReadFormAsync(); - if (!string.IsNullOrWhiteSpace(eventName)) - { - context.Items.Add(HydroConsts.ContextItems.EventName, eventName.ToString()); - } - } - else if (!string.IsNullOrWhiteSpace(method)) - { - context.Items.Add(HydroConsts.ContextItems.MethodName, method); - } - else - { - context.Items.Add(HydroConsts.ContextItems.IsBind, true); - } + var formValues = hydroData + .Where(f => !f.Key.StartsWith("__hydro")) + .ToDictionary(f => f.Key, f => f.Value); + + var model = hydroData["__hydro_model"].First(); + var type = hydroData["__hydro_type"].First(); + var parameters = JsonConvert.DeserializeObject>(hydroData["__hydro_parameters"].FirstOrDefault("{}")); + var eventData = JsonConvert.DeserializeObject(hydroData["__hydro_event"].FirstOrDefault(string.Empty)); + var componentIds = JsonConvert.DeserializeObject(hydroData["__hydro_componentIds"].FirstOrDefault("[]")); + var form = new FormCollection(formValues); + + context.Items.Add(HydroConsts.ContextItems.RenderedComponentIds, componentIds); + context.Items.Add(HydroConsts.ContextItems.BaseModel, model); + context.Items.Add(HydroConsts.ContextItems.Parameters, parameters); + + if (eventData != null) + { + context.Items.Add(HydroConsts.ContextItems.EventName, eventData.Name); + context.Items.Add(HydroConsts.ContextItems.EventData, eventData.Data); } - if (context.Request.HasFormContentType) + if (!string.IsNullOrWhiteSpace(method) && type != "event") { - var form = await context.Request.ReadFormAsync(); - context.Items.Add(HydroConsts.ContextItems.RequestForm, form); + context.Items.Add(HydroConsts.ContextItems.MethodName, method); } - else + + if (form.Any()) { - var body = await new StreamReader(context.Request.Body).ReadToEndAsync(); - - if (!string.IsNullOrWhiteSpace(body)) - { - if (method == "event") - { - context.Items.Add(HydroConsts.ContextItems.EventData, body); - } - else - { - context.Items.Add(HydroConsts.ContextItems.RequestData, body); - } - } + context.Items.Add(HydroConsts.ContextItems.RequestForm, form); } } diff --git a/src/HydroConsts.cs b/src/HydroConsts.cs index 6692eec..713d241 100644 --- a/src/HydroConsts.cs +++ b/src/HydroConsts.cs @@ -4,15 +4,10 @@ internal static class HydroConsts { public static class RequestHeaders { - public const string Model = "hydro-model"; - public const string EventName = "hydro-event-name"; - public const string ClientEventName = "Hydro-Client-Event-Name"; public const string Boosted = "Hydro-Boosted"; public const string Hydro = "Hydro-Request"; - public const string Parameters = "Hydro-Parameters"; public const string OperationId = "Hydro-Operation-Id"; public const string Payload = "Hydro-Payload"; - public const string RenderedComponentIds = "hydro-all-ids"; } public static class ResponseHeaders @@ -28,10 +23,9 @@ public static class ContextItems public const string RenderedComponentIds = "hydro-all-ids"; public const string EventName = "hydro-event"; public const string MethodName = "hydro-method"; - public const string IsBind = "hydro-bind"; public const string BaseModel = "hydro-base-model"; public const string RequestForm = "hydro-request-form"; - public const string RequestData = "hydro-request-model"; + public const string Parameters = "hydro-parameters"; public const string EventData = "hydro-event-model"; public const string IsRootRendered = "hydro-root-rendered"; } diff --git a/src/HydroEventPayload.cs b/src/HydroEventPayload.cs new file mode 100644 index 0000000..e1c98bc --- /dev/null +++ b/src/HydroEventPayload.cs @@ -0,0 +1,9 @@ +namespace Hydro; + +internal class HydroEventPayload +{ + public string Name { get; set; } + public object Data { get; set; } + public Scope Scope { get; set; } + public string OperationId { get; set; } +} \ No newline at end of file diff --git a/src/Scripts/hydro.js b/src/Scripts/hydro.js index c09ac48..70c40fd 100644 --- a/src/Scripts/hydro.js +++ b/src/Scripts/hydro.js @@ -108,10 +108,9 @@ async function hydroEvent(el, url, eventData) { const operationId = eventData.operationId; - const body = JSON.stringify(eventData.data); const hydroEvent = el.getAttribute("x-on-hydro-event"); const wireEventData = JSON.parse(hydroEvent); - await hydroRequest(el, url, 'application/json', body, 'event', wireEventData, operationId); + await hydroRequest(el, url, { eventData: { name: wireEventData.name, data: JSON.stringify(eventData.data) } }, 'event', wireEventData, operationId); } async function hydroBind(el) { @@ -154,7 +153,7 @@ // binding[url].formData = new FormData(); const bindOperationId = generateGuid(); dirty[propertyName] = bindOperationId; - await hydroRequest(el, url, null, requestFormData, 'bind', null, bindOperationId); + await hydroRequest(el, url, { formData: requestFormData }, 'bind', null, bindOperationId); if (dirty[propertyName] === bindOperationId) { delete dirty[propertyName]; } @@ -163,7 +162,7 @@ }); } - async function hydroAction(el, component, action, clientEvent) { + async function hydroAction(el, component, action) { const url = `/hydro/${component.name}/${action.name}`; if (Array.from(el.attributes).some(attr => attr.name.startsWith('x-hydro-bind')) && isElementDirty(el)) { @@ -180,12 +179,12 @@ const operationId = generateGuid(); el.setAttribute("hydro-operation-id", operationId); - await hydroRequest(el, url, null, null, null, null, operationId, true, JSON.stringify(action.parameters || {}), clientEvent); + await hydroRequest(el, url, { parameters: action.parameters }, 'action', null, operationId, true); } let operationStatus = {}; - async function hydroRequest(el, url, contentType, body, type, eventData, operationId, morphActiveElement, params, clientEvent) { + async function hydroRequest(el, url, requestData, type, eventData, operationId, morphActiveElement) { if (!document.contains(el)) { return; } @@ -216,41 +215,35 @@ operationStatus[operationId]++; } - + await enqueueHydroPromise(componentId, async () => { try { let headers = { 'Hydro-Request': 'true' }; - if (contentType) { - headers['Content-Type'] = contentType; + if (config.Antiforgery) { + headers[config.Antiforgery.HeaderName] = config.Antiforgery.Token; } - if (clientEvent) { - headers['Hydro-Client-Event-Name'] = clientEvent.type; + let requestForm = requestData?.formData || new FormData(); + + requestForm.append('__hydro_type', type); + + if (requestData.parameters) { + requestForm.append('__hydro_parameters', JSON.stringify(requestData.parameters)) } - if (config.Antiforgery) { - headers[config.Antiforgery.HeaderName] = config.Antiforgery.Token; + if (requestData.eventData) { + requestForm.append('__hydro_event', JSON.stringify(requestData.eventData)) } const scripts = component.querySelectorAll('script[data-id]'); const dataIds = Array.from(scripts).map(script => script.getAttribute('data-id')).filter(d => d !== componentId); - headers['hydro-all-ids'] = JSON.stringify([componentId, ...dataIds]); + requestForm.append('__hydro_componentIds', JSON.stringify([componentId, ...dataIds])); const scriptTag = component.querySelector(`script[data-id='${componentId}']`); - headers['hydro-model'] = scriptTag.textContent; - - if (eventData) { - headers['hydro-event-name'] = eventData.name; - } - - const parameters = params || el.getAttribute("hydro-parameters"); - - if (parameters) { - headers['Hydro-Parameters'] = parameters; - } + requestForm.append('__hydro_model', scriptTag.textContent); if (operationId) { headers['Hydro-Operation-Id'] = operationId; @@ -262,7 +255,7 @@ const response = await fetch(url, { method: 'POST', - body: body, + body: requestForm, headers: headers }); @@ -431,7 +424,6 @@ loadPageContent, findComponent, generateGuid, - hydroRequest, config }; } @@ -522,7 +514,7 @@ document.addEventListener('alpine:init', () => { }); }).before('on'); - Alpine.directive('hydro-polling', Alpine.skipDuringClone((el, { value, expression, modifiers }, { effect, cleanup }) => { + Alpine.directive('hydro-polling', Alpine.skipDuringClone((el, { value, expression, modifiers }, { cleanup }) => { let isQueued = false; let interval; const component = window.Hydro.findComponent(el); @@ -536,7 +528,7 @@ document.addEventListener('alpine:init', () => { return; } - await window.Hydro.hydroAction(el, component, { name: expression }, null); + await window.Hydro.hydroAction(el, component, { name: expression }); }, time); } @@ -616,7 +608,7 @@ document.addEventListener('alpine:init', () => { }); }); - Alpine.directive('hydro-focus', (el, { expression }, { effect, cleanup }) => { + Alpine.directive('hydro-focus', (el, { expression }, { effect }) => { effect(() => { el.querySelector(expression || 'input').focus(); }); @@ -634,7 +626,7 @@ document.addEventListener('alpine:init', () => { if (["click", "submit"].includes(e.type) && ['A', 'BUTTON'].includes(this.$el.tagName)) { e.preventDefault(); } - await window.Hydro.hydroAction(this.$el, this.$component, action, e); + await window.Hydro.hydroAction(this.$el, this.$component, action); }, async bind(debounce) { let element = this.$el; diff --git a/src/TagHelpers/HydroOnTagHelper.cs b/src/TagHelpers/HydroOnTagHelper.cs index ec6add8..ca9fd33 100644 --- a/src/TagHelpers/HydroOnTagHelper.cs +++ b/src/TagHelpers/HydroOnTagHelper.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Newtonsoft.Json; +using static Hydro.ExpressionExtensions; namespace Hydro.TagHelpers; @@ -51,19 +52,16 @@ public override void Process(TagHelperContext context, TagHelperOutput output) foreach (var eventItem in _handlers) { - var eventData = eventItem.Value.GetNameAndParameters(); + var jsExpression = GetJsExpression(eventItem.Value); - if (eventData == null) + if (jsExpression == null) { continue; } var eventDefinition = eventItem.Key; - - var jsInvokeExpression = GetJsInvokeExpression(eventData.Value.Name, eventData.Value.Parameters); - output.Attributes.RemoveAll(HandlersPrefix + eventDefinition); - output.Attributes.Add(new TagHelperAttribute($"x-on:{eventDefinition}", new HtmlString(jsInvokeExpression), HtmlAttributeValueStyle.SingleQuotes)); + output.Attributes.Add(new TagHelperAttribute($"x-on:{eventDefinition}", new HtmlString(jsExpression), HtmlAttributeValueStyle.SingleQuotes)); if (Disable || new[] { "click", "submit" }.Any(e => e.StartsWith(e))) { @@ -72,15 +70,53 @@ public override void Process(TagHelperContext context, TagHelperOutput output) } } - private static string GetJsInvokeExpression(string name, IDictionary parameters) + private static string GetJsExpression(Expression expression) + { + var clientAction = GetJsClientActionExpression(expression); + + if (clientAction != null) + { + return clientAction; + } + + return GetJsInvokeExpression(expression); + } + + private static string GetJsClientActionExpression(Expression expression) + { + if (expression is not { Body: MethodCallExpression methodCall } + || methodCall.Method.DeclaringType != typeof(HydroClientActions)) + { + return null; + } + + switch (methodCall.Method.Name) + { + case nameof(HydroClientActions.Invoke): + var expressionValue = EvaluateExpressionValue(methodCall.Arguments[0]); + return ReplaceJsQuotes(expressionValue?.ToString()); + + default: + return null; + } + } + + private static string GetJsInvokeExpression(Expression expression) { + var eventData = expression.GetNameAndParameters(); + + if (eventData == null) + { + return null; + } + var invokeJson = JsonConvert.SerializeObject(new { - Name = name, - Parameters = parameters + eventData.Value.Name, + eventData.Value.Parameters }, JsonSettings.SerializerSettings); - var invokeJsObject = ExpressionExtensions.DecodeJsExpressionsInJson(invokeJson); + var invokeJsObject = DecodeJsExpressionsInJson(invokeJson); return $"invoke($event, {invokeJsObject})"; }