Wednesday, August 8, 2018

Sitecore SXA, rules engine & containers in partial designs

SXA and partial designs

While working on a Sitecore site with SXA I created several partial designs (as you should when working with SXA). What a partial design is, and how it is used to create page designs that define the layout of your pages in SXA can be read in the official docs.

What you should know about partial designs is that you edit them on the partial design item itself, not on the pages where they are used. On the final page in the experience editor, you can edit the content shown by the partial design but you cannot change the design itself (add, remove or change components or their properties). Still, partial designs are very useful and help you keep the number of components on a page reasonable. On many occasions though, you will want the editors to be able to add components to the page that are not preset in the partial design. This can be done by adding a "container" component in the partial design (note: this is one way - not necessarily the way) on the spot where you want to give the editors a new placeholder (a placeholder is a spot where new components can be added). And so we did...

And then came the designers... and they gave our pages a very nice design. But they had a problem: empty containers!

The containers that were added to the partial design might remain empty. And with our design, this made it hard for the designers to get the required result. So: lets remove those empty containers.

SXA & the Container component

As mentioned, the container component was just added to have a placeholder available. It's a typical "structure" component that adds a wrapper for other renderings.

In the experience editor we want the placeholder to be always available (to be able to add components) but in normal or preview mode we want it gone when no renderings were added. 

The container component generates a placeholder named "container-x" where x is a dynamic placeholder id. 
Hint: when using the container, you should also set the placeholder restrictions for the generated placeholders - as you should do always probably ;)  More info on how to do this with SXA can be found on the official docs.
 To remove the components, I used the Sitecore rules engine. 

Using the rules engine

We want to use conditional rendering to hide the component when the related placeholder is empty. The action to hide a rendering is out-of-the-box available, so we only need the "condition". 

In this condition, we first check the PageMode to make sure we are in normal or preview mode.

We could ask the administrator adding the rule and using our condition to pass the related placeholder as a parameter (the easy way) but we can actually retrieve it.  The rendering (container) has a parameter called DynamicPlaceholderID that contains the generated dynamic placeholder id. In our case we hard-coded the prefix "container-" but you might do something pretty with that as well.

Last thing to do is find out if there is a rendering on the page in that placeholder..  which can be done with a query on the renderings list. 

Let's wrap this up and show the code:
public class WhenPlaceholderIsEmpty<T> : WhenCondition<T> where T : RuleContext
{
    protected override bool Execute(T ruleContext)
    {
        Assert.ArgumentNotNull(ruleContext, "ruleContext");
        if (!Sitecore.Context.PageMode.IsNormal && !Sitecore.Context.PageMode.IsPreview)
        {
            return false;
        }

 var conditionalRenderingsRuleContext = ruleContext as ConditionalRenderingsRuleContext;
        if (conditionalRenderingsRuleContext == null)
        {
            return false;
        }
  
        var dynamicPlaceholderId = HttpUtility.ParseQueryString(conditionalRenderingsRuleContext.Reference.Settings.Parameters).Get("DynamicPlaceholderID");
        if (dynamicPlaceholderId == null)
 {
            return false;
        }

        if (conditionalRenderingsRuleContext.Item.Visualization.GetRenderings(Sitecore.Context.Device, false)
            .ToList().Any(r => r.Placeholder.EndsWith("container-" + dynamicPlaceholderId, StringComparison.OrdinalIgnoreCase)))
        {
            return false;
        }

        return true;
    }
}

To quickly add this to condition to Sitecore, go to /sitecore/system/Settings/Rules/Definitions/Elements and create a new Element Folder.  In the created folder find tags/default and select Conditional Renderings as tag. In the root of the created element folder, add a new Condition. Fill in the Text like "when the placeholder in the container is empty" and add the Type (referal to the class above). Don't forget to publish the created items ;)

Personalization

Go to a partial design that contains a Container rendering. Open the presentation details (in content editor - you might also use the experience editor) and edit the controls. Use the personalization feature on the Container rendering and add a personalization rule using the newly created condition and the standard available "hide" action.  Save & publish.


Conclusion

Partial designs are a great way to organize your pages in SXA. And yes, they do put have some limitations. But it wouldn't be Sitecore if there was no way to do something about that and the rules engine is a rather powerful tool to help you achieve what is needed.

Monday, July 23, 2018

Overview pages with SXA search components

Sitecore SXA search components

Sitecore eXperience Accelerator comes with a bunch of components to create a search solution for your site. A few years ago -with SXA 1.1- I wrote a post on (some) of the search components and how to use them to create a site search. 

It wouldn't be Sitecore nor SXA when you could not tweak this a lot.. you can create specific searches, facetted results, and lots more..  Just check out the official docs.

When I found out that we can trigger the search results without user interaction I saw new possibilities.  We can create overview pages with the search components! This is no rocket science but probably worth sharing for people who are not so acquainted with SXA yet.


Create an overview page with SXA search components

Let's explain our setting: we have a number of products and want to create a page where the visitor can easily find the product (s) needs. We'll show the products and let them be filtered based on a few facets in order to get the visitor quickly to the wanted products with a link to the details.

So we have items of a template "Product" that are located in folders underneath our "product root node". 

Search scope

First thing to do is define our search scope. A search scope is used to limit the search results based on a query. Go to /Settings/Scopes in your sites folder to define a scope. In our case we created a Product Scope with a query on location (our product root node) and template (our product template). Use the "Build Query" dialog to create the query you need (the dialog performs the actual query so can preview the results). 



Search results

We can already create the search results to see if our listing is working. Select a page (or partial design) where you want the overview. Go to the experience editor and from the SXA toolbox drop the Search Results component on the page. 

The datasource of the search component is not really important here. This item contains the "Results not found" text, but we should always have results. 

What is important though are the properties of the control in the SearchCriteria section.
Make sure to:
  • select the scope (your created scope should be available here)
  • select "Automatically fire search when no criteria set" - this will make sure that your search is done automatically on the page




Variant
You'll probably want to create a variant for your search results. Create a new variant definition under /Presentation/Rendering Variants/Search Results in your site and select the variant on your search results component. In this variant you can define which fields (from the Product template) should be shown. Don't forget to set "Is Link" on at least one (the title) to have a link to the detail page.
More information on creating a variant on the official doc site.



More...
You can extend your page with more search components as needed. You'll probably want the "Page Selector" and "Page Size" components, maybe also the "Sort Results"..    More info on the available components for search can be found on the official doc site.

Facets

Next step is to add our facets to have filters on the results. Our template contains a few fields that we want to filter on. In our case those fields were of type DropList. This means that the raw value of the field is actually just a Guid. Of course, you don't want to display guids to your visitors..  so we had to add extra fields to our index to do this. 

Facet index field
It's been asked on StackExchange, but the information can be found on the official doc site. And yes, it's as simple as adding a few definitions for computed fields (not even code, as the code has been provided by SXA).

To be able to create the facet to a field that has a guid, add the following in a config patch file (check the config file for your search provider for the exact location where to patch this):
<field contentfield="title" fieldname="referenced" referencefield="link" type="Sitecore.XA.Foundation.Search.ComputedFields.ResolvedLinks, Sitecore.XA.Foundation.Search"></field>
where:
  • fieldName: defines the index field name
  • referenceField: name of the field in your template
  • contentField: name of the field to be fetched from the referenced item
Don't forget to rebuild your index after these changes...

Once we have the field in our index, we can create the facet in SXA.

SXA Facet
We will use facets to refine the search results. Facets in SXA are added to /Settings/Facets in your site. 

There are a few facet types (bool, date, integer, list ...) - we used list types in our case. It might be a bit tricky to get the "Field Name" field correctly as this has to be like in your index. Easiest way to be sure is to check your index (e.g. use the Solr admin interface) to find the exact field name in the index. In my case it was "fieldName_sm" (where fieldName is the one from our patch above). 

Adding the facets to the page
Last thing we need to do is add the facet components to our page, using the created facet settings.
Go back to the page (item) where you added the search results and open it again in the experience editor.

For each facet you have, add the Filter component you want. I used Dropdown and Checklist, but this will depend on what you actually want to filter on and how you want to present it to the visitor. 

When the component is dropped on the page, create a new datasource for it. In this datasource item, you can alter a few texts used on the component but the most important one is the Facet field. Select the appropriate facet here.

The results

When all this is done and published, you should have a nice overview page that has filters, paging, .. and everything you need to get your visitors to your detail pages. Good luck!

Extra

You will notice that filtering the results creates a new url each time. This means we could immediatly link to a filtered version of our page if we would want to...


ps: we are using SXA 1.7.1 at the time of writing

Tuesday, July 3, 2018

Sitecore admin page in 9.0.2

Sitecore admin pages

Most Sitecore developers will probably know that Sitecore has some admin pages in /sitecore/admin.
Jammykam wrote a very good overview on it in 2016 as we did get an overview page of the admin tools in v8.1.

Sitecore admin pages in 9.0.2


Recently I installed the (now) latest version of Sitecore, 9.0.2. The admin tools are still there, but wow.. they did get an update. The page went from looks-like-this-is-made-by-a-developer to something that fits in the admin interface of Sitecore.



As you can see there are also a few new additions. I won't go over the existing ones (they have been explained in the aformentioned blog by Kamruz or also here by Kris).

New additions

One that was added with Sitecore 9 already was the extended show config:: Show Config Layers. It will bring you to /sitecore/admin/ShowConfigLayers.aspx and allows you to see your configuration based on layers and roles. Would have been nice if environments were able as well, but haven't found that yet.


But the one I really wanted to mention, is a new and very cool one: the Support Package generator.  Yes, no need to install this anymore - you can generate your support packages from the admin section of the site now! Very nice, and always up to date. Available at /sitecore/admin/supportpackage.



Friday, May 11, 2018

Sitecore context language based on Geo IP location

Sitecore context language


Sitecore uses a context language (Sitecore.Context.Language) to fetch the correct version of an item to display. There is an out-of-the-box logic to determine the context language - it uses:
  1. The "sc_lang" query string parameter
  2. The language prefix in the url
  3. The language cookie for the site
  4. The default language in the site config.
  5. The DefaultLanguage setting in the Sitecore config
There are many other functional scenario's to determine the language of a (new) visitor and they can be developed in a custom processor that is placed in the httpRequestBegin pipeline. Create a class that overrides HttpRequestProcessor, implement an override for the Process method containing any logic you need and adapt the Sitecore Context properties (Language) as needed.

What if you want visitors to be directed to a language based on their location?

Geo IP detection

In Sitecore you can setup Geo IP location detection. This will add information to the Tracker about the location of your visitor. This information can be used for several purposes, and possibly also for language detection.

Geo IP to set the context language

We had to set the language of the new visitor to en-US when the visitor came from the USA.
As we had languages enabled in the urls, we only had to take care of the homepage - all other urls had a defined language already.

Seems so easy! Just create the language resolver as mentioned above and set the language based on the country in the tracker. Done.

Issue 1 : the tracker

If you place your language resolver where you normally would (just after the one from Sitecore) you'll notice that your tracker is not et initialized. This is normal. If you want more information on the complete pipeline sequence in a request, you should read Martin Davies' blog
Solution: put your resolver elsewhere..  :
<startAnalytics>
   <processor patch:after="*[@type='Sitecore.Analytics.Pipelines.StartAnalytics.StartTracking, Sitecore.Analytics']" type="your-language-resolver, .." />
</startAnalytics>
As this is another pipeline, we'll also use another base class: my processor derives from RenderLayoutProcessor - the rest stays the same.

Issue 2: the Geo IP

On the first visit, you are most likely to find no geo ip data in the request pipeline. It's just too soon. The request has been made (async), but the results are not there yet. An article by Pavel Veller got us in the right direction. A new CreateVisitProcessor was made:
public class UpdateGeoIpData : CreateVisitProcessor
{
    public override void Process(CreateVisitArgs args)
    {
        var url = WebUtil.GetRawUrl();
        if (!url.Equals("/", StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        args.Interaction.UpdateGeoIpData(TimeSpan.FromMilliseconds(500));
    }
}

<pipelines>
    <createVisit>
        <processor type="Sitecore.Analytics.Pipelines.CreateVisits.UpdateGeoIpData, Sitecore.Analytics">
          <patch:delete/>
        </processor>
        <processor type="...UpdateGeoIpData, .." patch:after="processor[@type='Sitecore.Analytics.Pipelines.CreateVisits.XForwardedFor, Sitecore.Analytics']" />
    </createVisit>
</pipelines>

This will make sure that we wait a little longer, but only on the pages where needed.

Resolving

if (Tracker.Current.Interaction.HasGeoIpData)
{
    if (Tracker.Current.Interaction.GeoData.Country.Equals("US", StringComparison.OrdinalIgnoreCase))
    {
        return "en-US";
    }
}

Our final logic looked something as above. We test the country from the GeoData and handle accordingly.

For the moment we are running some performance tests, but so far it seems ok.

Monday, April 30, 2018

SUGCON 2018 - Sitecore User group conference in Berlin


The Sitecore User Group Conference (SUGCON) is getting bigger and better every year. The 2018 edition in Berlin was definitely a success. The sessions were great. We had plenty of good food and drinks - not enough tables but that made us join and meet new people (or bump into old acquaintances) which brings me to the most important part, the attendees: 600 awesome Sitecore community people that make this conference to the annual high mass for all Sitecore users, developers, marketers, .... With a 'little' help of an amazing group of organizers (also community people) who did a tremendous job putting this all together.
A warm applause for the community and a big thanks for the organizers!

Day 1 sessions


Day 1 started with an opening keynote, highlighting three community pilars (Sitecore community forum - Sitecore Slack - Sitecore StackExchange) and a surprise announcement on Sitecore's release cadence. From now on Sitecore will release:

  • a "Software release" twice a year with new features
  • "Update releases" with fixes but no breaking changes when needed. 


Jason Wilkerson and Richard Seal got the conference really started with a trip to "SitecoreLand". A fictive amusement park with bracelets, fast lanes, and a Sitecore driven website and mobile app. They used the whole Sitecore spectrum: SXA (Sitecore Experience Accelerator), Sitecore Commerce, Marketing automation and xConnect with a special sauce of IoT (Internet of Things) to accomplish a proof of concept for an all-in customer experience. It was a call to all of us to dream.. to use our imagination..  because lots of things are possible.

Kam Figy went on stage to show us "Uber-modern API's for Sitecore". The rabbit out of his hat (can a Unicorn wear a hat?) was called GraphQL. This is all still in preview, but the demo's were cool (and working). He showed the graph browser, an alternative database browser and the integration in JSS. It was amazing, overwhelming, cool, and a little bit "wtf".. (but javascript is not my usual playground).


Next on stage was Alexei Veshalovich. He showed us an omni-channel demo for an unrecognizable car brand using (amongst others) Outlook Plugin, Xamarin, and from the Sitecore platform JSS, ExM, xConnect and the Marketing automation engine. And still no angry demo gods :)

My next session was Commerce minded: Sumith Damodaran presented the architecture of the new Sitecore Commerce 9 platform with an emphasis on plugins.
We learned about inventory sets and sellable items, the storage roles and the application roles like Shops, Authoring and Minions. Commerce Plugins are a (or the) way to extend and enhance the solution, knowing that the product itself also implements it's core features via plugins. And a final note on SXA Storefront for building B2C e-commerce solutions using Sitecore Experience Accelerator.

Evening entertainment

Sugcon is also the place where the award ceremony for the Sitecore Hackathon and the MVP's take place.

For the first time the hackathon results were not announced up front, so the tension with the participants was a little higher when Akshay Sura presented the winners. As our team had to withdraw due to illness ๐Ÿ˜ž we didn't renew our title but we're still proud to see our name -No Weekend 4 Us- on the slide with past winners. But: congratulations to all 2018 winners! We know they did an amazing job. And thanks to Akshay and all judges for investing their time to make this happen!



During the MVP ceremony the many mvp's, who spend quite some time making the Sitecore community as good as it is, are being celebrated and their physical award is handed out. And of course, this is picture time.

The entertainment this year was a "Community Feud" with Akshay Sura and Robin Hermanussen as show hosts and Pete Navarra and Jason St-Cyr as team captains.
It was fun.. and our team (with Pete as captain) won - the prize money was donated to the non-profit Girls in Tech. The questions and answers were based upon a questionaire filled in by community members some time ago. "What drink would Pieter Brinkman and Mark Frost have in a pub?" - I think Pete learned that we have quite some trolls, as "water" was a top answer ๐Ÿ™Š





Day 2 sessions

Day 2 started with two Honorary MVP's (Todd Mitchell & Lars Petersen) showing us the power of connected data - how to bring all sorts of data in xDB through xConnect and get it out again in a meaningful way. Amongst others they used a calculated facet as a way to capture data instead of keeping and counting a number of events.

For my next session I had the heartbreaking choice between the "EXM Live!" session by the magnificant Pete Navarra or the xConnect evolution by xDB guru Dmytro Shevchenko.

I decided to go for Dmytro and the underlying mechanics of Sitecore's new scalable architecture. This means I will have to watch Pete in Orlando ;) The xConnect session was interesting and a good follow-up of the session of last year. The differences between the locking mechanism in Sitecore 8 and 9 was explained - we went from pessimistic to optimistic - and code was shown to handle this. We're looking forward to Dmytro's posts on this ;)

Thomas Eldblom went on the main stage to give his last session as Sitecore employee (as we would learn later ๐Ÿ˜ž).  
Using SIF -the Sitecore Install Framework- live on stage installing several Sitecore architectures might be a challenge but he was well prepared and the demo's went very smooth. He showed the different setups provided by Sitecore and how to tweak SIF to install the ones that are not out-of-the-box. Even installing modules like the Publishing Service can be done with SIF.

As I missed Mark Stiles' session on cognitive services last year, I decided to join it now. It was a good overview of what companies like Google and Microsoft are offering on AI and how this can be leveraged. It made me curious to see where this is going:

I don't think Mikkel Rรธmer expected so many people in his "White hat hacker's guide to the internet". The break-out room was fully packed and heard that he tested 3K+ Sitecore sites on some known issues like the Telerik and the PushSession vulnerabilities and faulty configurations like open logins with or without the default password. As the results were quite astonishing - meaning too many sites were not ok - this was an eye opener for a lot of people. Patch your solutions! Read and act by the hardening guides! And to be honest, I'm not sure if I was worried most by his results or by the questions afterwards as some of those made clear there is still a lot of work to make all developers aware of security risks... 

Up to George Chang to give us some thoughts on Identity in Sitecore 9.
The new Federated Authentication feature in Sitecore 9 looks very good. I already read some blog posts on it and this session confirmed my feelings. Quite sure I will be trying this one out in the near future.






Back to the the main stage for two well-known community trolls telling us the story behind the new Forms: Kamruz Jaman and Mike Reynolds (or was it Reybolds?). I must admit I had expected a bit more customizations and code but that might be because I already saw quite a bit about Forms already (and presented a session on this topic myself on the local user group). But the session was a good overview of the features, the possibilities and the missing pieces of a Sitecore module that a lot of users have been waiting for. And.. they managed to get all of us on Slack -during the session (so I might have missed a bit)- by posting Forms data on Slack:

The last session slot was for Sitecore's X-team -represented by Alex Shyba & Adam Weber- to demonstrate the current status of the Sitecore Javascript Services (JSS). Heavy stuff after 2 days of conference...


Pieter Brinkman had the honour to close the conference and announce the next Sugcon Europe: mark April 4th/5th 2019 in your agenda and start learning English because the community is going to London (in Brexit-country).


Final thoughts

Sugcon is truly the place and time to meet all those wonderful community members you got to know online in real life. Have a chat with the guy (or girl) who helped you solve an issue, or vice versa. Meet the people behind the modules you might be using. Sadly -as I am used to arriving the evening before the conference and leaving right after- it's too short to meet you all so I surely missed quite a few people. Let's catch up in Orlando ;)  

But still it was good to see some old and new prominent SSE folks (good to wear your 5K-shirt, Chris), people I've worked with in the past, people I will work with in the near future... many known faces from Belgium (SugBelux), and (un)known faces from all around the globe.

Thanks to all organizers for creating this opportunity for the tremendous Sitecore community to meet and learn in person. Hey, we are omni-channel! 



Tuesday, March 6, 2018

Sitecore 9 configuration roles: content management, reporting, processing

Sitecore 9 configuration roles

A while ago I had to setup a few Sitecore servers with a topology as:
  • Content Delivery
  • Content Management (and all the rest)
In Sitecore 9 things have changed a little bit when setting up server roles. As we could read in the documention we have these roles at our disposal:
  • ContentDelivery
  • ContentManagement
  • Processing
  • Reporting
  • Standalone
For our CD server, the choice was easy: we set the server to the ContentDelivery role.
For our CM server, it was not that obvious. As the documentation mentioned combining roles is possible and it even mentions "ContentManagement, Processing, Reporting" as an example we though this would be a good idea. 

ContentManagement, Processing, Reporting

Unfortunately immediately after setting these roles, the server crashed. We noticed that we had to set remote settings for processing and reporting server which seemed very weird as our processing and reporting was not remote. 

I ended up asking this on SSE and also to Sitecore Support. Support logged this as a bug and gave us the solution - details on this can be found on SSE, so no need to copy them here.

With these changes, we had no need to set any remote settings. And the site worked!

But a few wise men asked me: "why are you doing this?". The only answer I had at that time was: "because I can"..  which is maybe not the best answer when explaining a server setup :)


Standalone

Before Sitecore 9 and the configuration roles we would have had a setup for this CM server that is now similar to a Standalone. Because that was easy. And disabling and enabling files was not...

So why not use standalone role now? Well, we assumed that as this server is not a content delivery one we would benefit from defining the roles. But just assuming is as wicked as using roles just because you can...

Compare configs

So let's compare the resulting configs.

There were quite some differences actually. I won't go into all the details as that is probably just boring but let's focus on some main points: [SO = standalone, CMPR = ContentManagement,Processing,Reporting]
  • databases: in a SO setup, core and master are set as default databases, in a CMPR setup these are all set to web
  • tracking: in a CMPR setup entries are removed regarding RobotDetection, form dropouts, session commits, content testing,..  
  • EXM: a lot of entries removed in CMPR, especially on tracking
So without going into details, I would assume that when not running in Standalone the website running on the ContentManagement server might not be doing your tracking and testing correctly and if entrying from a mail you'll also miss out.

But.. is this important? Will it make the server more performant? 
I think not. 

So what do you need to do? Well, you have a choice..  Also consider if you are using the server as a true test environment (using an extra publishing target perhaps) you probably do want ContentDelivery enabled, meaning a standalone setup.

The easy choice is: set it to standalone.
But.. if you would have separate Processing and/or Reporting roles, would you add ContentDelivery as a role to the ContentManagement server? Probably/maybe not.. so why do that when those roles get combined?

Because it's easy... :)

via GIPHY

Wednesday, February 7, 2018

Displaying extra data on a Sitecore 9 Goal

Goals in Sitecore 9

In our project we had goals set when a whitepaper was downloaded. This works fine, but it would be much nicer if we could show which whitepaper was actually downloaded. All the whitepapers have an identifier code, so the challenge was to add this identifier to the goal data.

Capturing custom goal data

Capturing the data is very well explained in the official documentation of Sitecore. We used the "Data" and "DataKey" properties of the goal:
  • DataKey: a key that identifies the contents of  "Data" - e.g. 'Whitepaper code'
  • Data : any data collected as part of triggering the event - in our case the whitepaper identifier
This all works fine and we can see the data in the database (xDB shards) as part of the events.

Displaying custom goal data

Data is nice, but you need a way to show it. It wasn't immediatly clear how to do this, so I asked it on Sitecore Stack Exchange.
Jarmo Jarvi pointed me in the good direction by mentioning a tremendous blog post by Jonathan Robbins. The blog post was based on Sitecore 8 - the idea behind my post here is to show the differences when doing this in Sitecore 9 and xConnect. The result will be the same:
an extra column in the goals section of the activity tab of a contact in the Experience Profile

ExperienceProfileContactViews

The ExperienceProfileContactViews pipeline is where all the magic happens. Adding the extra column to the results table is identical to the description for Sitecore 8. Fetching the data (in GetGoals) however is quite different as we have to use xConnect now:
public class AddGoalDataColumn : ReportProcessorBase
{
  public override void Process(ReportProcessorArgs args)
  {
    args.ResultTableForView?.Columns.Add(Schema.GoalData.ToColumn());
  }
}

public static class Schema
{
  public static ViewField GoalData = new ViewField("GoalData");
}

public class FillGoalData : ReportProcessorBase
{
  public override void Process(ReportProcessorArgs args)
  {
    var resultTableForView = args.ResultTableForView;
    Assert.IsNotNull(resultTableForView, "Result table for {0} could not be found.", args.ReportParameters.ViewName);
    var i = 0;
    foreach (var row in resultTableForView.AsEnumerable())
    {
      var goalData = args.QueryResult.Rows[i].ItemArray[4];
      if (goalData != null)
      {
        row[Schema.GoalData.Name] = goalData;
      }

      i++;
    }
  }
}
public class GetGoals : ReportProcessorBase
{
  public override void Process(ReportProcessorArgs args)
  {
    var goalsDataXconnect = GetGoalsDataXconnect(args.ReportParameters.ContactId);
    args.QueryResult = goalsDataXconnect;
  }

  private static DataTable GetGoalsDataXconnect(Guid contactId)
  {
    var goalsTableWithSchema = CreateGoalsTableWithSchema();
    var contactExpandOptions = new ContactExpandOptions(Array.Empty<string>())
    {
      Interactions = new RelatedInteractionsExpandOptions("WebVisit")
      {
        StartDateTime = DateTime.MinValue
      }
    };

    FillRawTable(GetContactByOptions(contactId, contactExpandOptions).Interactions.Where(p => p.Events.OfType<Goal>().Any()), goalsTableWithSchema);
    return goalsTableWithSchema;
  }

  private static DataTable CreateGoalsTableWithSchema()
  {
    var dataTable = new DataTable();
    dataTable.Columns.AddRange(new[]
    {
      new DataColumn("_id", typeof(Guid)),
      new DataColumn("ContactId", typeof(Guid)),
      new DataColumn("Pages_PageEvents_PageEventDefinitionId", typeof(Guid)),
      new DataColumn("Pages_PageEvents_DateTime", typeof(DateTime)),
      new DataColumn("Pages_PageEvents_Data", typeof(string)),
      new DataColumn("Pages_Url_Path", typeof(string)),
      new DataColumn("Pages_Url_QueryString", typeof(string)),
      new DataColumn("Pages_PageEvents_Value", typeof(int)),
      new DataColumn("Pages_Item__id", typeof(Guid)),
      new DataColumn("SiteName", typeof(string))
    });

    return dataTable;
  }

  private static void FillRawTable(IEnumerable<Interaction> goalsInteractions, DataTable rawTable)
  {
    foreach (var goalsInteraction in goalsInteractions)
    {
      foreach (var goal in goalsInteraction.Events.OfType<Goal>())
      {
        var currentEvent = goal;
        var row = rawTable.NewRow();
        row["_id"] = goalsInteraction.Id;
        row["ContactId"] = goalsInteraction.Contact.Id;
        row["Pages_PageEvents_PageEventDefinitionId"] = currentEvent.DefinitionId;
        row["Pages_PageEvents_DateTime"] = currentEvent.Timestamp;
        row["Pages_PageEvents_Data"] = currentEvent.Data;
        row["Pages_PageEvents_Value"] = currentEvent.EngagementValue;
        var dataRow = row;
        const string index = "SiteName";
        var webVisit = goalsInteraction.WebVisit();
        var str = webVisit?.SiteName ?? string.Empty;
        dataRow[index] = str;
        if (currentEvent.ParentEventId.HasValue)
        {
          if (goalsInteraction.Events.FirstOrDefault(p => p.Id == currentEvent.ParentEventId.Value) is PageViewEvent pageViewEvent)
          {
            row["Pages_Item__id"] = pageViewEvent.ItemId;
            var urlString = new UrlString(pageViewEvent.Url);
            row["Pages_Url_Path"] = urlString.Path;
            row["Pages_Url_QueryString"] = urlString.Query;
          }
        }

        rawTable.Rows.Add(row);
      }
    }
  }

  private static Contact GetContactByOptions(Guid contactId, ExpandOptions options = null)
  {
    using (var client = SitecoreXConnectClientConfiguration.GetClient())
    {
      if (options == null)
      {
        options = new ContactExpandOptions(Array.Empty<string>())
        {
          Interactions = new RelatedInteractionsExpandOptions("IpInfo", "WebVisit")
        };
      }

      var contactReference = new ContactReference(contactId);
      var contact = client.Get(contactReference, options);
      if (contact == null)
      {
        throw new ContactNotFoundException($"No Contact with id [{contactId}] found");
      }

      return contact;
    }
  }
}

One more change is the configuration. It is almost the same as in Sitecore 8, but we don't need to change the goals-query anymore - instead we have to patch the GetGoals in the same ExperienceProfileContactViews pipeline.
<sitecore>
  <pipelines>
    <group groupName="ExperienceProfileContactViews">
      <pipelines>
        <goals>
          <processor patch:after="*[@type='Sitecore.Cintel.Reporting.Contact.Goal.Processors.ConstructGoalsDataTable, Sitecore.Cintel']"
             type="MyNamespace.AddGoalDataColumn, MyProject" />
          <processor patch:after="*[@type='Sitecore.Cintel.Reporting.Contact.Goal.Processors.PopulateGoalsWithXdbData, Sitecore.Cintel']"
             type="MyNamespace.FillGoalData, MyProject" />
          <processor patch:instead="*[@type='Sitecore.Cintel.Reporting.ReportingServerDatasource.Goals.GetGoals, Sitecore.Cintel']"
             type="MyNamespace.GetGoals, MyProject" />
        </goals>
      </pipelines>
    </group>
  </pipelines>
</sitecore>

Final step - core database

As Jonathan mentioned, we do need to add a new item in the core database to display our new column in the list of Goals. Create this item under the path /sitecore/client/Applications/ExperienceProfile/Contact/PageSettings/Tabs/Activity/Activity Subtabs/Goals/GoalsPanel/Goals and set a descriptive HeaderText and a DataField value that matches the schema name.

Your result should look like this:



I woud like to thank Jonathan Robbins for his original blog post and Jarmo Jarvi for his answer on SSE.