// *************************************************************************** // // Delphi MVC Framework // // Copyright (c) 2010-2024 Daniele Teti and the DMVCFramework Team // // https://github.com/danieleteti/delphimvcframework // // Collaborators on this file: David Moorhouse (info@moorhouse.net.nz) // // *************************************************************************** // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // *************************************************************************** } unit MVCFramework.HTMX; {$I dmvcframework.inc} interface uses MVCFramework, MVCFramework.Commons, System.Rtti, JsonDataObjects, System.StrUtils; type THTMXRequestHeaderType = record public const CurrentUrl = 'HX-Current-URL'; HistoryRestoreRequest = 'HX-History-Restore-Request'; Prompt = 'HX-Prompt'; Request = 'HX-Request'; Target = 'HX-Target'; TriggerName = 'HX-Trigger-Name'; Trigger = 'HX-Trigger'; Boosted = 'HX-Boosted'; TriggeringEvent = 'Triggering-Event'; end; ///Helper class to expose HTMX headers as native functions on WebRequest objects THTMXRequestHelper = class helper for TMVCWebRequest private function GetHtmxHeader(Header: string): string; function GetHtmxHeaderToBool(Header: string): Boolean; function HasHeader(Header: string): Boolean; public /// Indicates that the request is triggered by HTMX. function IsHTMX: Boolean; /// Indicates that the request is via an element using hx-boost. function HXIsBoosted: Boolean; /// True if the request is for history restoration after a miss in the local history cache function HXIsHistoryRestoreRequest: Boolean; /// The current URL of the browser. function HXGetCurrentUrl: string; /// The user response to an hx-prompt. function HXGetPrompt: string; /// The id of the target element if it exists. function HXGetTarget: string; /// The id of the triggered element if it exists. function HXGetTrigger: string; /// The name of the triggered element if it exists. function HXGetTriggerName: string; /// The value of the header is a JSON serialized /// Requires the event-header extension to be installed and loaded on the page /// https://htmx.org/extensions/event-header/ function HXGetTriggeringEvent: TArray; /// The value of the header is a JSON serialized /// Requires the event-header extension to be installed and loaded on the page /// https://htmx.org/extensions/event-header/ function HXGetTriggeringEventAsJSON: TJsonObject; end; THTMXResponseHeaderType = record public const Location = 'HX-Location'; Refresh = 'HX-Refresh'; PushURL = 'HX-Push-Url'; Redirect = 'HX-Redirect'; ReplaceURL = 'HX-Replace-Url'; Reselect = 'HX-Reselect'; Reswap = 'HX-Reswap'; Retarget = 'HX-Retarget'; Trigger = 'HX-Trigger'; TriggerAfterSettle = 'HX-Trigger-After-Settle'; TriggerAfterSwap = 'HX-Trigger-After-Swap'; end; ///Helper class to expose HTMX options as native functions on WebResponse objects THTMXResponseHelper = class helper for TMVCWebResponse public type TClientEventType = (etReceived, etSettled, etSwapped); TSwapOption = (soInnerHTML, soOuterHTML, soBeforeBegin, soAfterBegin, soBeforeEnd, soAfterEnd, soDelete, soNone); TShowScrollType = (ssNone, ssShow, ssScroll); TSwapScrollTo = (sstTop, sstBottom); private const ClientEventTypes: array [TClientEventType] of string = (THTMXResponseHeaderType.Trigger, THTMXResponseHeaderType.TriggerAfterSettle, THTMXResponseHeaderType.TriggerAfterSwap); SwapOptions: array [TSwapOption] of string = ('innerHTML', 'outerHTML', 'beforebegin', 'afterbegin', 'beforeend', 'afterend', 'delete', 'none'); ShowScrollTypes: array [TShowScrollType] of string = ('', 'show', 'scroll'); SwapScrollTo: array [TSwapScrollTo] of string = ('top', 'bottom'); public /// Pushes a new url into the browser history history. /// This creates a new history entry, allowing navigation with the browser�s back and forward buttons. /// This is similar to the hx-push-url attribute. /// If present, this header overrides any behavior defined with attributes. /// A URL to be pushed into the location bar. /// This may be relative or absolute, as per history.pushState(). /// If omitted, the header will output "false", which prevents the browser�s history from being updated. function HXSetPushUrl(URL: string = ''): TMVCWebResponse; /// Replaces the current URL in the browser location history. /// This does not create a new history entry; in effect, it removes the previous current URL from the browser�s history. /// This is similar to the hx-replace-url attribute. /// If present, this header overrides any behavior defined with attributes. /// A URL to replace the current URL in the location bar. /// This may be relative or absolute, as per history.replaceState(), but must have the same origin as the current URL. /// If omitted, the header will output "false", which prevents the browser�s current URL from being updated. function HXSetReplaceUrl(URL: string = ''): TMVCWebResponse; /// Allows you to specify how the response will be swapped. See hx-swap for possible values /// Check if transition: true works ? function HXSetReswap(Option: TSwapOption): TMVCWebResponse; overload; /// Allows you to specify how the response will be swapped. See hx-swap for possible values /// You can modify the timing of the browser update to synchronize htmx with the timing of CSS transition effects. /// The amount of time that htmx will wait after receiving a response to swap the content (in milliseconds) default is 0 /// The amount of time between the swap and the settle logic(in milliseconds) default is 20mS function HXSetReswap(Option: TSwapOption; SwapDelay: Integer; SettleDelay: Integer = 20): TMVCWebResponse; overload; /// Allows you to specify how the response will be swapped. See hx-swap for possible values /// You can modify nature of the browser update to show or scroll to the top or bottom of a target. /// The target for the swap /// Whether to set the display to the target, or to scroll to the target /// Either top or bottom /// Allows targetting of a different element for scrolling or showing function HXSetReswap(Option: TSwapOption; ShowScroll: TShowScrollType; &To: TSwapScrollTo; Selector: string = '') : TMVCWebResponse; overload; /// A CSS selector that updates the target of the content to a different element on the page function HXSetRetarget(Selector: string): TMVCWebResponse; /// Allows you to trigger a client side event. /// Using events gives you a lot of flexibility to add functionality to normal htmx responses. /// The name of the javscript event to be triggered /// The timing of the event function HXTriggerClientEvent(Name: string; After: TClientEventType = etReceived): TMVCWebResponse; overload; /// Allows you to trigger a collection of client side events. /// Using events gives you a lot of flexibility to add functionality to normal htmx responses. /// A collection of the names of the javscript events to be triggered /// The timing of the event function HXTriggerClientEvents(Names: TArray; After: TClientEventType = etReceived): TMVCWebResponse; /// Allows you to trigger a client side event with parameters. /// Using events gives you a lot of flexibility to add functionality to normal htmx responses. /// The name of the javscript event to be triggered /// An object containing the parameters to be sent to the event /// The timing of the event function HXTriggerClientEvent(Name: string; Params: TValue; After: TClientEventType = etReceived): TMVCWebResponse; overload; /// if set to �true� the client side will do a a full refresh of the page function HXSetPageRefresh(Refresh: Boolean = true): TMVCWebResponse; /// Allows you to do a client-side redirect that does not do a full page reload /// Instead of changing the page�s location it will act like following a hx-boost link, creating a new history entry, /// issuing an ajax request to the value of the header and pushing the path into history. function HXSetLocation(Path: string): TMVCWebResponse; overload; /// Used to do a client-side redirect to a new location function HXSetRedirect(Path: string): TMVCWebResponse; /// Sends an error response bcack to client. function HXSetErrorResponse(ErrorCode: Integer; ErrorMessage: string): TMVCWebResponse; /// A CSS selector that allows you to choose which part of the response is used to be swapped in. /// Overrides an existing hx-select on the triggering element /// A CSS selector function HXSetReSelect(Selector: string): TMVCWebResponse; end; implementation uses System.SysUtils, MVCFramework.Utils, MVCFramework.Serializer.JsonDataObjects, MVCFramework.Serializer.Commons; { THTMXRequestHelper } function THTMXRequestHelper.HXGetCurrentUrl: string; begin Result := GetHtmxHeader(THTMXRequestHeaderType.CurrentUrl); end; function THTMXRequestHelper.GetHtmxHeader(Header: string): string; begin Result := Headers[Header]; end; function THTMXRequestHelper.GetHtmxHeaderToBool(Header: string): Boolean; begin Result := SameText('true', Headers[Header]); end; function THTMXRequestHelper.HXGetPrompt: string; begin Result := GetHtmxHeader(THTMXRequestHeaderType.Prompt); end; function THTMXRequestHelper.HXGetTarget: string; begin Result := GetHtmxHeader(THTMXRequestHeaderType.Target); end; function THTMXRequestHelper.HXGetTrigger: string; begin Result := GetHtmxHeader(THTMXRequestHeaderType.Trigger); end; function THTMXRequestHelper.HXGetTriggeringEvent: TArray; begin Result := nil; if HasHeader(THTMXRequestHeaderType.TriggeringEvent) then Result := GetHtmxHeader(THTMXRequestHeaderType.TriggeringEvent).Split([',']); end; function THTMXRequestHelper.HXGetTriggeringEventAsJSON: TJsonObject; begin Result := nil; if HasHeader(THTMXRequestHeaderType.TriggeringEvent) then Result := TJsonBaseObject.Parse(GetHtmxHeader(THTMXRequestHeaderType.TriggeringEvent)) as TJsonObject; end; function THTMXRequestHelper.HXGetTriggerName: string; begin Result := GetHtmxHeader(THTMXRequestHeaderType.TriggerName); end; function THTMXRequestHelper.HasHeader(Header: string): Boolean; begin Result := not Headers[Header].IsEmpty; end; function THTMXRequestHelper.HXIsBoosted: Boolean; begin Result := GetHtmxHeaderToBool(THTMXRequestHeaderType.Boosted); end; function THTMXRequestHelper.HXIsHistoryRestoreRequest: Boolean; begin Result := GetHtmxHeaderToBool(THTMXRequestHeaderType.HistoryRestoreRequest); end; function THTMXRequestHelper.IsHTMX: Boolean; begin Result := GetHtmxHeaderToBool(THTMXRequestHeaderType.Request); end; { THTMXResponseHelper } function THTMXResponseHelper.HXSetErrorResponse(ErrorCode: Integer; ErrorMessage: string): TMVCWebResponse; begin Self.StatusCode := ErrorCode; Self.Content := '{"error":"' + ErrorMessage + '"}'; Result := Self; end; function THTMXResponseHelper.HXSetLocation(Path: string): TMVCWebResponse; begin SetCustomHeader(THTMXResponseHeaderType.Location, Path); Result := Self; (* todo: This response header can be used to trigger a client side redirection without reloading the whole page. Instead of changing the page�s location it will act like following a hx-boost link, creating a new history entry, issuing an ajax request to the value of the header and pushing the path into history. A sample response would be: HX-Location: /test Which would push the client to test as if the user had clicked on If you want to redirect to a specific target on the page rather than the default of document.body, you can pass more details along with the event, by using JSON for the value of the header: HX-Location: {"path":"/test2", "target":"#testdiv"} Path is required and is url to load the response from. The rest of the data mirrors the ajax api context, which is: source - the source element of the request event - an event that �triggered� the request handler - a callback that will handle the response HTML target - the target to swap the response into swap - how the response will be swapped in relative to the target values - values to submit with the request headers - headers to submit with the request *) end; function THTMXResponseHelper.HXSetPageRefresh(Refresh: Boolean): TMVCWebResponse; begin SetCustomHeader(THTMXResponseHeaderType.Refresh, ifthen(Refresh, 'true','false')); //must be lowercase Result := Self; end; function THTMXResponseHelper.HXSetPushUrl(URL: string): TMVCWebResponse; begin if URL.IsEmpty then URL := 'false'; SetCustomHeader(THTMXResponseHeaderType.PushURL, URL); Result := Self; end; function THTMXResponseHelper.HXSetRedirect(Path: string): TMVCWebResponse; begin SetCustomHeader(THTMXResponseHeaderType.Redirect, Path); Result := Self; end; function THTMXResponseHelper.HXSetReplaceUrl(URL: string): TMVCWebResponse; begin if URL.IsEmpty then URL := 'false'; SetCustomHeader(THTMXResponseHeaderType.ReplaceURL, URL); Result := Self; end; function THTMXResponseHelper.HXSetReSelect(Selector: string): TMVCWebResponse; begin SetCustomHeader(THTMXResponseHeaderType.Reselect, Selector); Result := Self; end; function THTMXResponseHelper.HXSetReswap(Option: TSwapOption; ShowScroll: TShowScrollType; &To: TSwapScrollTo; Selector: string = ''): TMVCWebResponse; var Modifiers: string; begin if (ShowScroll <> ssNone) then begin Modifiers := Format(' %s', [ShowScrollTypes[ShowScroll]]); if not Selector.IsEmpty then Modifiers := Format('%s:%s', [Modifiers, Selector]); Modifiers := Format('%s:%s', [Modifiers, SwapScrollTo[&To]]); end; SetCustomHeader(THTMXResponseHeaderType.Reswap, SwapOptions[Option] + Modifiers); Result := Self; end; function THTMXResponseHelper.HXSetReswap(Option: TSwapOption; SwapDelay, SettleDelay: Integer): TMVCWebResponse; var Modifiers: string; begin if SwapDelay > 0 then Modifiers := Format('swap:%dms ', [SwapDelay]); if (SettleDelay > 0) and (SettleDelay <> 20) then Modifiers := Modifiers + Format('settle:%dms', [SettleDelay]); if not Modifiers.IsEmpty then Modifiers := ' ' + Modifiers.Trim; SetCustomHeader(THTMXResponseHeaderType.Reswap, SwapOptions[Option] + Modifiers); Result := Self; end; function THTMXResponseHelper.HXSetReswap(Option: TSwapOption): TMVCWebResponse; begin // todo: support Focus scroll ? Result := HXSetReswap(Option, 0, 0); end; function THTMXResponseHelper.HXSetRetarget(Selector: string): TMVCWebResponse; begin SetCustomHeader(THTMXResponseHeaderType.Retarget, Selector); Result := Self; end; function THTMXResponseHelper.HXTriggerClientEvent(Name: string; After: TClientEventType): TMVCWebResponse; begin Result := HXTriggerClientEvent(Name, TValue.Empty, After); end; function THTMXResponseHelper.HXTriggerClientEvent(Name: string; Params: TValue; After: TClientEventType): TMVCWebResponse; var ser: TMVCJsonDataObjectsSerializer; Data: TJsonObject; begin if not Params.IsEmpty then begin Data := TJsonObject.Create; ser := TMVCJsonDataObjectsSerializer.Create; try ser.TValueToJSONObjectProperty(Data, Name, Params, stdefault, [], []); SetCustomHeader(ClientEventTypes[After], Data.ToJSON); finally ser.Free; Data.Free; end; end else SetCustomHeader(ClientEventTypes[After], Name); Result := Self; end; function THTMXResponseHelper.HXTriggerClientEvents(Names: TArray; After: TClientEventType): TMVCWebResponse; var Value: string; begin if Length(Names) = 0 then Exit(Self); Value := Names[0]; for var I := Low(Names) + 1 to High(Names) do Value := Value + ', ' + Names[I]; SetCustomHeader(ClientEventTypes[After], Value); Result := Self; end; end.