Change auto-routing to allow for query parameters

Currently automatic form routing does exact matches against the current hash instead of stopping after a '?'.
This effectivley prevents the use of this feature in combination with query parameter.
Purpose: Ability to facilitate autorouting to URLs like #EditCustomerForm?id=1234 or #SearchForm?name=abc.

Current workaround would be not using auto routing and copy paste the TApplication.Route (OnAutoCreateForm becomes unusable if needed) as well as deriving Forms of an intermediate class that overrides HandleHashChange.

Both changes are easy doable in WEBLib.Forms.pas:

diff --git a/WEBLib.Forms.pas b/WEBLib.Forms.fix.pas
index 380953c..c37ccf1 100644
--- a/WEBLib.Forms.pas
+++ b/WEBLib.Forms.fix.pas
@@ -2673,10 +2673,8 @@ var
   pc: TPersistentClass;
   newform: TForm;
 begin
-  asm
-    oldURL = Event.oldURL;
-    newURL = Event.newURL;
-  end;
+  oldURL := TJSHashChangeEvent(Event).oldURL;
+  newURL := TJSHashChangeEvent(Event).newURL;

   if Assigned(OnHashChange) then
   begin
@@ -2691,8 +2689,8 @@ begin

     if Assigned(pc) and (LastHash <> NewHash) then
     begin
-      if (pos('#' + Name + '_', oldURL + '_') <> 0) and
-         (pos('#' + Name + '_', newURL + '_') = 0) then
+      if (pos('#' + Name + '?', oldURL + '?') <> 0) and
+         (pos('#' + Name + '?', newURL + '?') = 0) then
       begin
         Close;
       end;
@@ -4495,6 +4493,7 @@ var
   frm: TCustomForm;
   hash: string;
   pc: TPersistentClass;
+  i: Integer;
 begin
   Result := false;
   FRouting := true;
@@ -4502,8 +4501,10 @@ begin

   if (hash <> '') then
   begin
-    delete(hash,1,1);
-    hash := 'T' + hash;
+    hash[1] := 'T';
+    i := Pos('?', hash);
+    if i > 0 then
+      SetLength(hash, i - 1);
     pc := GetClass(hash);
     if Assigned(pc) then
     begin

Additional functionality to somehow pass parameters using autorouting would be useful to have as well.
This currently has to be done rather inconveniently like e.g.
Application.Navigate('#EditCustomerForm?id=' + IntToStr(CustomerID), ntPage);

This is a good suggestion indeed and we'll incorporate these suggested improvements in the next release.

A functional fix including passing parameters could look like this:

diff --git a/WEBLib.Forms.orig.pas b/WEBLib.Forms.fix.pas
index 380953c..49d402c 100644
--- a/WEBLib.Forms.orig.pas
+++ b/WEBLib.Forms.fix.pas
@@ -277,6 +277,7 @@ type
     FOnDOMContentLoaded: TNotifyEvent;
     FShowClose: boolean;
     FOnHashChange: THashChangeEvent;
+    FQueryString: string;
     procedure SetModalResult(const Value: TModalResult);
     function GetFormStyle: TFormStyle;
     procedure SetFormStyle(const Value: TFormStyle); virtual;
@@ -366,6 +367,7 @@ type
     procedure CreateInitialize; override;
     constructor Create(id: string); overload; override;
     constructor Create(id: string; var AReference); overload; virtual;
+    constructor Create(id, query: string; var AReference); overload; virtual;
     constructor Create(AOwner: TComponent); overload; override;
     constructor Create(AOwner: TComponent; IsPopup: boolean; ABorder: TFormBorderStyle = fbSingle; ATitle: string = ''); overload;
     constructor CreateNew(AOwner: TComponent; Dummy: Integer = 0); overload; virtual;
@@ -717,6 +719,7 @@ type
     FHotReloadVersion: Integer;
     FHotReloadFile: string;
     {$ENDIF}
+    FQueryString: string;
     function DoFormLoad(Event: TEventListenerEvent): boolean;
     function DoFormAbort(Event: TEventListenerEvent): boolean;
     function DoHandleError(Event: TJSErrorEvent): boolean;
@@ -856,6 +859,7 @@ type
     property HotReloadPollInterval: Integer read FHotReloadPollInterval write FHotReloadPollInterval;
     property HotReloadFile: string read FHotReloadFile write FHotReloadFile;
 {$ENDIF}
+    property QueryString: string read FQueryString write FQueryString;
   end;

 function GetParentForm(AControl: TControl): TCustomForm;
@@ -1124,6 +1128,8 @@ begin
   begin
     hash := ClassName;
     delete(hash,1,1);
+    if FQueryString <> '' then
+      hash := hash + '?' + FQueryString;
     LastHash := hash;
     window.location.hash := hash;
   end;
@@ -2669,14 +2675,13 @@ end;

 function TCustomForm.HandleHashChange(Event: TEventListenerEvent): boolean;
 var
-  oldURL,newURL,newHash: string;
+  oldURL,newURL,newHash,appQS: string;
   pc: TPersistentClass;
   newform: TForm;
+  i: Integer;
 begin
-  asm
-    oldURL = Event.oldURL;
-    newURL = Event.newURL;
-  end;
+  oldURL := TJSHashChangeEvent(Event).oldURL;
+  newURL := TJSHashChangeEvent(Event).newURL;

   if Assigned(OnHashChange) then
   begin
@@ -2686,23 +2691,31 @@ begin
   if Application.FRouting then
   begin
     NewHash := copy(newURL, pos('#', newURL) + 1, Length(newURL));
-
     pc := GetClass('T' + Name);

     if Assigned(pc) and (LastHash <> NewHash) then
     begin
-      if (pos('#' + Name + '_', oldURL + '_') <> 0) and
-         (pos('#' + Name + '_', newURL + '_') = 0) then
+      if (pos('#' + Name + '?', oldURL + '?') <> 0) and
+         (pos('#' + Name + '?', newURL + '?') = 0) then
       begin
         Close;
       end;

       if not Popup then
       begin
+        i := Pos('?', NewHash);
+        if i > 0 then
+        begin
+          appQS := Application.QueryString;
+          Application.QueryString := Copy(NewHash, i + 1, Length(NewHash));
+          SetLength(NewHash, i - 1);
+        end;
         pc := GetClass('T' + NewHash);
         if Assigned(pc) then
         begin
           Application.AutoCreateForm(TFormClass(pc), newform);
+          if i > 0 then
+            Application.QueryString := appQS;
         end;
       end;
     end;
@@ -2955,6 +2968,19 @@ begin
   DoCreate;
 end;

+constructor TCustomForm.Create(id, query: string; var AReference);
+begin
+  FQueryString := query;
+  FCreating := true;
+  FFormContainer := id;
+  inherited Create(id);
+  AReference := Self;
+  FFormElement := '';
+  FModalResult := mrNone;
+  FFormStyle := fsNormal;
+  DoCreate;
+end;
+
 procedure TCustomForm.CreateInitialize;
 begin
   inherited;
@@ -3168,6 +3194,7 @@ end;
 procedure TApplication.CreateForm(AInstanceClass: TFormClass; AElementID: string; var AReference; AProc: TFormCreatedProc; LoadHTML: boolean);
 var
   LFileName: string;
+  LQueryString: string;

   function DoStatusCreate(Event: TEventListenerEvent): boolean;
   var
@@ -3199,7 +3226,7 @@ var
       LElem := TJSHTMLElement(document.body);
     end;

-    LForm := AInstanceClass.Create(AElementID, AReference);
+    LForm := AInstanceClass.Create(AElementID, LQueryString, AReference);
     LForm.FormFileName := LFileName;

     if LForm.FormContainer = '' then
@@ -3251,6 +3278,7 @@ begin
   end;

   LFileName := AInstanceClass.GetHTMLFileName;
+  LQueryString := FQueryString;
   if (LFileName <> '') and LoadHTML then
   begin
     FLastReq := TJSXMLHttpRequest.new;
@@ -4493,8 +4521,9 @@ end;
 function TApplication.Route: boolean;
 var
   frm: TCustomForm;
-  hash: string;
+  hash, appQS: string;
   pc: TPersistentClass;
+  i: Integer;
 begin
   Result := false;
   FRouting := true;
@@ -4502,13 +4531,21 @@ begin

   if (hash <> '') then
   begin
-    delete(hash,1,1);
-    hash := 'T' + hash;
+    hash[1] := 'T';
+    i := Pos('?', hash);
+    if i > 0 then
+    begin
+      appQS := Application.QueryString;
+      Application.QueryString := Copy(hash, i + 1, Length(hash));
+      SetLength(hash, i - 1);
+    end;
     pc := GetClass(hash);
     if Assigned(pc) then
     begin
       Result := true;
       Application.AutoCreateForm(TFormClass(pc), frm);
+      if i > 0 then
+        Application.QueryString := appQS;
     end;
   end;
 end;

To set a query string when navigating by code simply do:

  Application.QueryString := 'index=12';
  Application.CreateForm(TForm2, Form2);
  Application.QueryString := '';

You can easily test this in the routing demo by throwing in above into the dpr or at some of the Forms codes.

This could be moved to an additional CreateForm overload as well, removing the requirement of resetting the QS but clogging the overloads further.

I'm not sure HandleHashChange should take any different action when only the query string part changed.

It will currently .Close the active form if the form part does not match anymore
and AutoCreateForm with the new query string. Basically the old logic.

Code like window.location.hash := '#Form2?abc=def'; works properly too as it's running through the HandleHashChange.

Not sure if I have overseen any other thing.

I'm still not sure what would be the more appropiate way to go about this.

With the above fix the query parameters would be encoded inside the hash.

  • Pro: No page reload is required as the forms get dynamically replaced in page.
  • Con: This is incompatible with window.location.search and thus not throw-in compatible to code using the existing GetQueryParam or similar code.

The alternative would be a full page reload during autoload.

  • Pro: Fits into existing GetQueryParam and more logical from browser view.
  • Con: Kinda violates the single page app paradigm as this would now destroy all state and rebuild from scratch whenever search params are used.

Required change for GetQueryParam when using the hash for parameters:

In case of ?x=y#Form?x=z it would favor the hash parameter over the search parmeter.

diff --git a/WEBLib.WebTools.orig.pas b/WEBLib.WebTools.pas
index d83f688..bb41759 100644
--- a/WEBLib.WebTools.orig.pas
+++ b/WEBLib.WebTools.pas
@@ -75,7 +75,7 @@ function GetMaterialGlyph(AGlyph: string; ASize: integer = 0; AColor: TColor = c
 implementation

 uses
-  SysUtils, WEBLib.Utils;
+  SysUtils, WEBLib.Utils, System.StrUtils;

 function EmptyImage: string;
 begin
@@ -446,38 +446,33 @@ end;

 function HasQueryParam(AName: string; var AValue: string; CaseSensitive: boolean = true): boolean;
 var
-  found: boolean;
-  s: string;
+  ts: TStringList;
+  i: Integer;
+  function GetValue(s: String): Boolean;
+  begin
+    ts.Text := s;
+    i := ts.IndexOfName(AName);
+    if i >= 0 then
+    begin
+      Result := True;
+      AValue := ts.ValueFromIndex[i];
+    end else
+      Result := False;
+  end;
 begin
-  s := '';
+  Result := False;
   if not CaseSensitive then
     AName := Uppercase(AName);

-  asm
-    var query = window.location.search.substring(1);
-    var res = "";
-    var key = "";
-    found = false;
-    var vars = query.split('&');
-    for (var i = 0; i < vars.length; i++) {
-      var pair = vars[i].split('=');
-
-      key = decodeURIComponent(pair[0]);
-      if (CaseSensitive == false)
-      {
-        key = key.toUpperCase();
-      }
-
-      if (key == AName) {
-          res = decodeURIComponent(pair[1]);
-          found = true;
-       }
-    }
-    s = res;
-  end;
+  ts := TStringList.Create;
+  i := window.location.hash.IndexOf('?');
+  if i > 0 then
+    Result := GetValue(window.location.hash.Substring(i + 1));
+
+  if not Result then
+    Result := GetValue(window.location.search.Substring(1));

-  AValue := s;
-  Result := found;
+  ts.Free;
 end;

 function GetQueryParam(AName: string): string;

Waiting for further feedback if thatd be your favored way or if everything should be moved into the search field (at the cost of forcing page reloads + state loss).