Blog

All Blog Posts  |  Next Post  |  Previous Post

Extend TMS WEB Core with JS Libraries with Andrew: jQuery

Bookmarks: 

Tuesday, May 3, 2022

TMS Software Delphi  Components

In previous articles in this series, we've covered some big JS libraries like Bootstrap and FontAwesome.  But there's another important JS library out there.  It can be found lurking under approximately 75% of all websites globally.  It first arrived on the scene more than 15 years ago.  And it is likely to be with us for the foreseeable future, even though its use has started to decline. I'm talking of course about jQuery.  But while you can use Bootstrap and FontAwesome in your TMS WEB Core projects without needing to resort to JavaScript coding, the same cannot really be said for jQuery.  At its core, jQuery can be thought of as a kind of extension of sorts to the JavaScript language.  Their motto is Write Less, Do More. We'll be taking a look at where jQuery might find a good fit in TMS WEB Core projects, and also some of the unexpected challenges that might arise.

Good jQuery

First, the briefest of histories.  jQuery first surfaced around 2006.  It was a time when writing JavaScript code meant that you had to be very much aware of the target browser that would be running your code.  Standards of the day were tenuous at best.  And as the saying goes, the great thing about standards is that there are so many to choose from. jQuery offered developers a consistent approach to writing JavaScript (an API, essentially) which meant that you could write code using jQuery that would then work across many browsers.  So naturally it rose quickly in popularity.  In fact, it became so popular, particularly among new developers, that it has been said that some developers struggle to write actual Vanilla JavaScript (aka JavaScript that doesn't make use of any frameworks like jQuery). Not surprising given how easy it has been to use jQuery when compared to the Vanilla JS equivalents of even just a few years ago.   

TMS Software Delphi  Components

jQuery has also evolved over the years to keep up with current web trends, and perhaps has even helped drive to some degree the adoption of new browser technologies. Even if some of those technologies ultimately end up removing the need for jQuery in the first place.  Progress, all the same.  And given the almost ubiquitous presence of jQuery, many (previously, most) JS libraries have been built on top of it, further ensuring it has a long life ahead of it.  Last time out, we looked at BigText, one such JS library, with jQuery as its only dependency.  Bootstrap 4 was also dependent on jQuery, but this was removed for Bootstrap 5.  Removing jQuery as a dependency seems to be a bit of a trend of late, perhaps ironically at the same time that many JS libraries are increasingly being released with variants that are specifically tailored to other (competing) JS frameworks. 

Less Good jQuery

Being around for such a long time, and being actively improved all the while, naturally a good deal of bloat is destined to be part of the mix, as would be the case in any software product.  For some projects, this will be the largest JS library in the project, dwarfing all others and negatively impacting the overall performance of the resulting web application.  While I don't doubt that this can be a real problem, we also now live in a world with mobile phones that have dozens and dozens of GB of RAM, 100 Mbps+ wireless connections, more CPU cores than ever, and a screen resolution substantially higher than what most people have on their desktops.  The extra 85 kb or so that jquery.min.js will take to download doesn't seem like much to get worked up about. If you're serving millions of pages an hour, and the rest of your page is more like 10 kb, then sure, getting rid of jQuery might be a top priority.

The bigger problem is that other tools have evolved that are better for coding larger projects.  Injecting random bits of jQuery everywhere in your codebase is perhaps not ideal from a long term project management perspective, and jQuery does nothing to discourage exactly that kind of behaviour.  Newer JavaScript frameworks, like React, Vue, Angular,  and of course TMS WEB Core, help provide a lot more organization and structure to your projects and thus result in more maintainable code.  

Beyond these JS frameworks, JavaScript itself has also continued to evolve.  Many of the inconsistencies and incompatibilities have been ironed out, particularly at the lower levels that jQuery operates at.  Much of jQuery's core functionality is actually now part of the JavaScript standard, with implementations that routinely outperform their jQuery equivalents.  Which means that removing jQuery from a project can make it better.  Both in terms of not needing to load the jQuery library, but also less code and faster code overall. Solid incentives for any project, if the bits of jQuery that are used fall into this area.  But not all of jQuery has been supplanted (yet), and a huge number of projects still have jQuery as a dependency with no plans for its removal.  jQuery will be with us for some time to come.

A short comment on standards, generally.  Whenever a project like jQuery becomes so popular, it becomes in effect a standard.  And sometimes standards that come about in this way are good.  Sometimes they aren't so good.  I'm sure many can remember Steve Jobs' Thoughts on Flash as an example of where this isn't always so good.  I think jQuery falls on the good side of the ledger, personally, but there are lots of well-meaning folks who are of the opinion that it should be put out to pasture.  Of course, as soon as a piece of technology becomes popular there are factions that inevitably rise up to champion its demise.  Maybe that's just me excitedly entering the "get off my lawn" stage of life but so long as they keep their grubby hands off of SQL, we're all good here.

About That Dependency

Whether you're adding jQuery to your TMS WEB Core project to satisfy a dependency requirement listed by another JS library, or because you just happen to like jQuery, adding it to your project is just as quick and easy as any other JS library.  Just bring up the JavaScript Library Manager, scroll to the very bottom and you'll find jQuery 3.5.1.
 
TMS Software Delphi  Components

As usual, you can also just edit the Project.html file and add the requisite link.  The only thing to mention is that it should come before any other JS libraries that list it as a dependency.  By default, the link provided by the JavaScript Library Manager references the jQuery site directly, rather than another CDN.

    <script crossorigin="anonymous" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" src="https://code.jquery.com/jquery-3.5.1.min.js" type="text/javascript"></script>

There are other ways to get jQuery, and there are other ways to incorporate jQuery in your project if you're interested in compiling your own version.  This would allow you to potentially leave out bits you don't need, or perhaps even add in new bits if you plan on customizing jQuery for use in many projects. But as with many other topics that we'll run across, this typically isn't something you'd do unless you're operating at a really large scale. If you're just trying to add jQuery to your project to satisfy the dependency requirements of another JS library, good news! You're all set.  If you're planning to use jQuery directly, then we've got more ground to cover.

jQuery, the organization, also has other products that are based on or related to jQuery and it can sometimes be confusing.  For example, jQueryUI is a set of extra components and other user interface-related supporting infrastructure that you can make use of just as easily as jQuery.  And in the list of libraries found by default in the Javascript Library Manager, you'll find other vendors' products that sit atop jQuery, like jQWidgets in the screenshot above.  These are all just additional JS libraries like any other, that can be added to your project in the same way and used just like any other JS library, albeit more closely integrated with jQuery itself and of course with jQuery as a dependency.

jQuery Day One

JavaScript as a language is a large and complex topic, and I'm not really wanting to cover it all here.  However, to make use of jQuery directly, it helps to get a bit of an understanding of the very basics of how to use it.  Using jQuery within a TMS WEB Core project means, necessarily, that any jQuery code will be encapsulated in asm... ...end blocks.  I'll do my best to not leave any out. So let's try out something simple.  Say you've got a form with three TWebButtons, WebButton1, WebButton2 and WebButton3, each with their Caption and ElementID properties set to the same value as their Name property.  And all three have the same ElementClassName property value of "btn btn-secondary" - Bootstrap classes that are used to style generic buttons. Should look something like the following.

TMS Software Delphi  Components
 
Running the project will get you three buttons in the default Bootstrap 'secondary' style.  Nothing particularly remarkable about this, of course.  
TMS Software Delphi  Components  

By default, if you click any of the buttons, nothing happens.  Back in the IDE, you can easily add code for each button to do something.  So let's say you want a WebButton1 click to change its Caption to say something else. Nobody should have any problem doing this in Delphi, but just for fun we'll also assume we also have FontAwesome available.

    procedure TForm2.WebButton1Click(Sender: TObject);
    begin
      WebButton1.Caption := 'Got it <i class="fas fa-thumbs-up text-info"></i>';
    end;
 

TMS Software Delphi  Components

Can't get much easier than that.  So how would we do this directly in JavaScript?  Lots of ways.  Running within TMS WEB Core, we can reference WebButton1 directly, so it is almost the same, just need to change the assignment operator from := to = and the name of the property that we're changing from Caption to innerHTML.

     procedure TForm2.WebButton1Click(Sender: TObject);
     begin
       asm
         WebButton1.innerHTML = 'Got it <i class="fas fa-thumbs-up text-info"></i>';
       end;
     end;

We can also do a lot of work directly in Delphi without having to resort to JavaScript using the same sort of JavaScript calls.  This can be a handy way to get around dealing with non-local variables, for example, because we're still in Delphi and don't need to be concerned about the JavaScript scope situation.  Most TWeb* components have an ElementHandle property that is a TJSHTMLElement type.  So you could, without resorting to JavaScript, write the above like this.  

     procedure TForm2.WebButton1Click(Sender: TObject);
     begin
       WebButton1.ElementHandle.innerHTML := 'Got it <i class="fas fa-thumbs-up text-info"></i>';
     end;

If we pretend that we're not running within TMS WEB Core, or we want to update an element that doesn't map directly to a TMS WEB Core component on our form. Then the problem is that we have to find a reference to WebButton1 before we can update it.  There are a number of reasons why this might be the case, but it shows up so often in nearly every JavaScript code example that one would think it is the preferred way to reference elements.  In Vanilla JavaScript, then, you can lookup the reference to WebButton1 and then change its property, like this.

     procedure TForm2.WebButton1Click(Sender: TObject);
     begin
       asm
         document.getElementById('WebButton1').innerHTML = 'Got it <i class="fas fa-thumbs-up text-info"></i>';
       end;
     end;

This lookup, or query, function is searching the entire DOM for the element named WebButton1, which we set earlier with the ElementID property.  In our case, the DOM is very small and this won't take any noticeable time at all.  DOM refers to Document Object Model - basically the code view of your web page.  One can readily imagine that in larger pages, with hundreds or perhaps even many thousands of elements in some random hierarchy, finding an element quickly is crticially important.  And this is largely where jQuery's origins (and name) can be traced - querying the JavaScript DOM.  To write this same code using jQuery, it would look like this.

     procedure TForm2.WebButton1Click(Sender: TObject);
     begin
       asm
         $('#WebButton1').html('Got it <i class="fas fa-thumbs-up text-info"></i>');
       end;
     end;

Note that $(#) is the replacement function for document.getElementById and that instead of setting the innerHTML property value, we're calling the html function. jQuery prefers functions over attributes as this allows for chaining operations together.  And there are a lot of jQuery functions that can be added.  We'll be using functions like delay(), fadeIn() and fadeOut() are examples.  So if you wanted to update the Caption, wait two seconds and then have the entire button disappear, it would look like this.

     procedure TForm2.WebButton1Click(Sender: TObject);
     begin
       asm
         $('#WebButton1').html('Got it <i class="fas fa-thumbs-up text-info"></i>').delay(2000).fadeOut(500);
       end;
     end;

The delay() function in jQuery isn't really like a Delphi sleep() call.  JavaScript has setTimeout() for that.  It is more about inserting a delay in jQuery's animation queue - the list of animations pending for a given element.  To do the above in Vanilla JavaScript would thus require two setTimeout functions and something more to animate the value of the opacity of the element from 1 down to 0.   Doable of course, but likely not as trivial to code.  And this is the crux of the issue with jQuery.  It is entirely possible to do away with jQuery but you have to update the code to use the non-jQuery equivalents.  Which can be tedious if this is what all your code looks like.

Force Multiplier

What if you wanted a WebButton1 click to instead change the caption on all three buttons?  In Delphi, JS and jQuery you could just copy & paste the code for each and update WebButton1 with WebButton2 and WebButton3 of course.  But let's assume that they represent an arbitrary number of buttons on the page.  And let's assume that we don't know what they are called, just that they have a common property, ElementClassName, and we're interested in those with the value of 'btn' included.  This might not happen often in a Delphi VCL app, but it is a frequent occurence in web applications.  In a Delphi VCL app, you'd likely be reaching for FindComponent and iterating through all the TWebButton classes, checking each for the property in question.  Or if you planned to do this in advance, you'd have a TObject array to search instead. But in jQuery, this is trivial.

     procedure TForm2.WebButton1Click(Sender: TObject);
     begin
       asm
         $('.btn').html('Got it <i class="fas fa-thumbs-up text-info"></i>');
       end;
     end;
 TMS Software Delphi  Components

Instead of $(#) we use $(.).  Anyone familiar with CSS will immediately recognize the difference between '#' representing an element's ID (which is unique per-element in the page) and '.' representing a single class associated with an element, where there can be any number of non-unique classes associated with any element in the page.  The equivalent version in Vanilla JavaScript is likely going to be document.getElementsbyClassName() which will return a list of elements that can then be used to apply the change. 

     procedure TForm2.WebButton1Click(Sender: TObject);
     begin
       asm
         var elements = document.getElementsByClassName('btn');
         for (var i = 0; i < elements.length; i++) {
           elements[i].innerHTML = 'Got it <i class="fas fa-thumbs-up text-info"></i>';
         }       
       end;
     end;

Again, quite possible, but not as clean. The Vanilla JavaScript code is likely to execute faster than the jQuery equivalent due to not having the overhead of jQuery, but this is not likely something that is going to matter the vast majority of the time. We're certainly not going to notice it here in our example, but if you were doing this across many objects with more complex class searches going on all the time, it starts to add up and the result could potentially be a sluggish user interface.  It might still be sluggish even without jQuery of course, but this is where the concerns about the overhead of jQuery start to factor into the picture.

You can also use another JavaScript function to do this, document.querySelectorAll(). It works the same way but is a little bit faster, and is what we'll be using in our examples that follow. There are some other key differences in how it works at a lower level, stuff about Nodes vs. HTMLCollections and live versus not-live results being returned, but none of that matters for our purposes here.  What is good to know is that there are often many different ways to do the same thing and that optimizing JavaScript is something that is very closely examined by legions of developers.  So if you need that extra bit of performance, there is almost always an endless rabbit hole of ideas available about how to get it.

About That Sample Project

In Sample Project v3 that was included with the last blog posting, all of this gets put to use in a few places.  First, let's tackle the easy ones.
 TMS Software Delphi  Components

The main part of the interface is a huge pile of buttons, each representing a different Bootstrap class.  When you click on one of these buttons, it adds that class to the ElementClassName property of whatever example component you're looking at, like a TWebButton for example.  And whenever that list of classes is edited, either directly or through the use of these buttons, all the buttons are updated to highlight those that are included in the list.  This highlighting is done by swapping btn-primary (highlighted) with btn-secondary (not highlighted).  The SelectButtons procedure is called to do this, which has as parameters the list of buttons we're considering, and those buttons which need to be highlighted.  

procedure TForm1.SelectButtons(BtnGroup: String; ActiveBtns: String);
begin
  // Here we want to set all BtnGroup btns to btn-secondary
  // except for ActiveBtns Captions which we want to be btn-primary
  asm
    ActiveBtns = ' '+ActiveBtns+' ';
    var btns = document.querySelectorAll(BtnGroup);
    for (var i = 0; i < btns.length; i++) {
      if (ActiveBtns.includes(' '+btns[i].innerText+' ')) {
        btns[i].classList.remove('btn-secondary');
        btns[i].classList.add('btn-primary');
      }
      else {
        btns[i].classList.add('btn-secondary');
        btns[i].classList.remove('btn-primary');
      }
    }
  end;
end;

Both the main menu at the top left and the theme menu at the top right use a similar tactic, but only one button is expected to be highlighted and it is passed as an ElementID value instead of a Caption value to the SelectButton procedure.

procedure TForm1.SelectButton(BtnGroup: String; ActiveBtn: String);
begin
  // Here we want to set all BtnGroup btns to btn-secondary
  // except for ActiveBtn ID which we want to be btn-primary
  asm
    var btns = document.querySelectorAll(BtnGroup);
    for (var i = 0; i < btns.length; i++) {
      if (btns[i].id == ActiveBtn) {
        btns[i].classList.remove('btn-secondary');
        btns[i].classList.add('btn-primary');
      }
      else {
        btns[i].classList.add('btn-secondary');
        btns[i].classList.remove('btn-primary');
      }
    }
  end;
end;

Neither SelectButtons() nor SelectButton() make use of jQuery because it isn't necessary and wouldn't be any faster.  And we do care a little bit about performance here.  Particularly SelectButtons performance as there are a lot of buttons and this is called between keypressess when editing the ElementClassName property.  So you'll notice if it is really slow.

With BigText, we do something similar, where we need to find all elements with a particular class and then run a jQuery function against them.  Here's what it looks like.

  asm
    function ApplyBigText() {
      var elems = document.querySelectorAll('.bigtext');
      for (var i = 0; i < elems.length; i++) {
        var elem = elems[i];
        if      (elem.classList.contains('bigtext-min-8'  )) { $(elem).bigtext({minfontsize:8 }) }
        else if (elem.classList.contains('bigtext-min-10' )) { $(elem).bigtext({minfontsize:10}) }
        else if (elem.classList.contains('bigtext-min-12' )) { $(elem).bigtext({minfontsize:12}) }
        else if (elem.classList.contains('bigtext-min-14' )) { $(elem).bigtext({minfontsize:14}) }
        else if (elem.classList.contains('bigtext-max-24' )) { $(elem).bigtext({maxfontsize:24}) }
        else if (elem.classList.contains('bigtext-max-36' )) { $(elem).bigtext({maxfontsize:36}) }
        else if (elem.classList.contains('bigtext-max-48' )) { $(elem).bigtext({maxfontsize:48}) }
        else if (elem.classList.contains('bigtext-max-24' )) { $(elem).bigtext({maxfontsize:60}) }
        else { $(elem).bigtext() }
      }
    }
    window.ApplyBigText = ApplyBigText;
  end;
  
In this case, BigText is actually a jQuery function, .bigtext() that is called on each element that has bigtext as a class.  The parameters used when calling .bigtext() depend on whether a second class is also included in the classes of that element.  This appoach means that we only need to make one document.querySelectorAll call. Using an extra class to narrow the search, and to search only once, is not uncommon.  For example, this happens with buttons in Bootstrap, where you have to specifiy both btn and btn-primary to get it to work properly.  In our Sample Project, the ApplyBigText() function is called as we're resizing an element. If it takes too long, the resizing smoothness will be impacted.  It's already noticable with just the one call, but I've not looked further to see whether that is due to document.querySelectorAll() or .bigtext() or Interact.js.

Difficulty++

The last major bit of JavaScript code in the sample project is concerned with updating the dimension values related to the element being resized or dragged.  Here again we're using the same approach of document.querySelectorAll() to find the elements that we're going to update.  And we use the jQuery .fadeIn() function to make it a little nicer when it is made visible.  The little extra bit of difficulty comes in trying to make it go away again, and I think is somewhat representative as to the kinds of challenges that come with having jQuery.  Where you get 98% of the way there, but that last 2% is a real pain.  
 
TMS Software Delphi  Components

    function interactInfo(x, y, w, h, dx, dy, dw, dh) {
     //...code left out for brevity
      txt = 'x: '+x+' y: '+y+' w: '+w+' h:'+h+' &Delta;x:'+dx+' &Delta;y:'+dy+' &Delta;w:'+dw+' &Delta;h:'+dh;
      elems = document.querySelectorAll('.interact-full');                     // (1) below
      for (var i = 0; i < elems.length; i++) {
        elem = elems[i];
        if (elem.innerHTML !== txt) {                                          // (2) below
          elem.innerHTML = txt;                                                // (3) below
          if (!$(elem).is(':visible')) {$(elem).stop(true,true).fadeIn(400)}   // (4) below
          tmr = elem.getAttribute('tmr');                                      // (5) below
          if (tmr !== null) { clearTimeout(tmr); }
          tmr = setTimeout(function() {$(elem).fadeOut(800); },3000);
          elem.setAttribute('tmr',tmr);
        }
      }
    }
    window.interactInfo = interactInfo;

So what the heck is going on here?  The user experience I'm after is what you see in the image.  I want the dimensions to fade in when the element is dragged or resized.  And I want this information to update continually while the element is being dragged or resized.  And once it stops being dragged or resized for a few seconds, I'd like the dimensions to fade away.  So first I'll go over what it is doing.  And then I'll explain why it is doing it this way.
  1. First, get the list of elements that might be used to display this information.  It is honestly likely only going to be one element but do the same search regardless.  In a production app, this would likely just reference that one element and not bother with the querySelectorAll()
  2. Check to see if it is already displaying the same information.  If it hasn't changed we don't bother with it at all, even if it is not visible
  3. Otherwise, set the value to be displayed
  4. If it is not already visibile, fade it in quickly. The call to .stop(true,true) before .fadeIn(400) is done to cancel any .fadeOut() that might be happening and just jump to the .fadeIn() part.
  5. Here, a timer is set for 3 seconds, after which the element will be faded out a little slower than it was faded in.  If a timer was already set, it is disposed of and a new one put in its place.  Note that this happens every time the text is updated.  So a bit of a pain, but it gets the job done.
Now you might be thinking, as I did, that an initial attempt at this fadeIn/fadeOut effect would be much simpler.  At (4) the following could be done.

    if (!$(elem).is(':visible')) {$(elem).fadeIn(400).delay(3000).fadeOut(800); 

And, technically, this looks like it should work fine.  If you just resized an element once, this would in fact fade in the dimensions for three seconds, and then fade out more slowly, just as we'd expect.  Where things go sideways is when this gets called multiple times or when the drag or resize takes longer than three seconds.  If you slowly drag the element for longer, for example, the dimensions will flash in and out as the delay timer kicks in.  Adding the .stop() call in the final implementation helped a bit with the fade in just after the 3-second mark, but in jQuery, calling .stop() unfortunately doesn't cancel .delay() so it ends up a bit of a mess.  One of those little details that cause a lot of unnecessary grief.

The solution then is to have a 3-second timer start as soon as the dimensions are shown, using a JavaScript .setTimeout() call instead of jQuery. And each time the dimensions are updated, that timer is canceled and a new 3-second timer is started.  Totally outside of the scope of jQuery, but it gets the job done.  This is also how you might do it if you wanted to handle it in Delphi code instead of JavaScript.  Setup a TWebTimer with an Interval of 3 seconds.  Then disable and enable it whenever the dimensions change, and have it disable itself when it is triggered.  So in this instance, using Delphi to solve the problem would be actually simpler (coding-wise) than either Vanilla JavaScript or jQuery.  How fun is that ?!

More jQuery

There are hundreds more jQuery functions that can be used to do all kinds of things.  Plenty of functions for manipulating DOM elements and their contents in every conceivable way.  A rich set of features related to JavaScript event handling.  Many more animation effects.  And so many ways to query and use CSS selectors.  The API is well documented, and I'm sure a sizable percentage of StackOverflow content is directly related to jQuery.  I'd wager, actually, that there is no larger support community for any software development tool than there is for jQuery.   

Sample Project v4

In preparing this article, it reminded me of some things that I had been lazy about when working on the Sample Project for this blog series.  So this time out, there are technically no new functionality in the project. But I've been on a bit of a performance improvement jaunt, so I've made a few changes.  Initialization time has gone from about 15s on my system to a little less than 2.5s.  A good reminder, again, that in JavaScript land the opportunities for tuning performance are endless, and often the effort isn't all that much.  In fact, most of this performance benefit came as a result of posting a few questions on the TMS Support Center, which got replies within an hour or so.  We, too, have an excellent support community (albeit a little bit smaller than jQuery at the moment) so by all means reach out if you have questions or would like to know more about any particular topic. 

While I'm against progress bars as a general principle, I realize that others may not be, so I've added a fancy circular progress bar to the app, using yet another JS library:  https://fdossena.com/?p=html5cool/radprog/i.frag.  This one is just embedded directly into the code, no CDN or anything.  Another option if the JS library is small enough.   

TMS Software Delphi  Components
Sample Project v4 Loading Screen

Other changes in Sample Project V4 include the following.
  • More comments for nearly everything in the code.
  • Added BeginUpdate/EndUpdate when creating TWebComboBox.Item elements -> huge performance increase.
  • Added BeginUpdate/EndUpdate and deferred class assignment when creating TWebButtons -> huge performance increase.
  • Removed document.getelementByID() references - not needed when you already have the ElementHandle ... handy.
  • Replaced classList.add/remove pairs with classList.replace.  Some of this is new to me too!
  • Fancy new Loading Screen with a TMS WEB Core reference on it as well
  • Numerous other little tweaks and bug fixes here and there.

Bring on the Controls

jQuery rounds out the three biggest JS libraries I wanted to cover in this blog series.  But so far, everything we've covered (aside from maybe the progress bar above) falls under the categories of Helpers, Tools, or Assets.  As developers, what we're often most interested in are Controls - the pieces of our project that users actually interact with.  The shiniest parts that we think about when we're developing new projects. While there are still a few little Helpers and Tools on the to-do list, the vast majority are all Controls.  Next time out we'll take a solid run at FlatPickr and see how easy it is to use in our TMS WEB Core projects.

Andrew Simard.



Masiha Zemarai


Bookmarks: 

This blog post has not received any comments yet.



Add a new comment

You will receive a confirmation mail with a link to validate your comment, please use a valid email address.
All fields are required.



All Blog Posts  |  Next Post  |  Previous Post