Select to view content in your preferred language

Custom widget filter feature layer

460
8
07-26-2024 05:50 PM
MK13
by
Frequent Contributor

I am new to custom widget development for experience builder and Javascript. I would like to filter a hard coded feature layer based on a field. I have studied the code at https://developers.arcgis.com/experience-builder/sample-code/widgets/filter-feature-layer/ but it's relying on a feature layer set by the user. In my case, I am not getting any input from the user. I would like to filter the feature layer after it's added to the map on the click of a button. 

@TonghuiMing @AlixVezina @ShengdiZhang @WeiYing1 @Shen_Zhang @TianWen @Junshan_Liu @Grant-S-Carroll @DanJiang 

0 Kudos
8 Replies
TimWestern
Frequent Contributor

I have a couple of thoughts on what you might be seeing.

First, when you are in the editor application for Exb, you can add data sources from the left:

TimWestern_0-1722343064287.png



This may only make it available to plug into from out of the box widgets though.

You may need to explore creating a settings section that allows you to select the layer it should be linked to, and then you can use similar code to access it based on the index it was added to useDataSources.

(I don't know how experienced at other Dev work you may be so I'll add some definitions:
  An index is basically a natural number (0 to n) that represents position in the useDataSources Array
  You can create an enumeration type that has a key on the left that you can know internally is to reference a particular DataSource, and a integer on the left.  this article has some examples of different enums, but the values probably can be simply 0, 1, 2, 3, 4... 100 depending on how many data sources you need to add in settings for the widget.  
)

EXB Widget settings are configured when you drag or add a widget to the application canvas, and are saved when you click save. (note if you change the order of settings, or remove one in the middle, you may have to read all the ones in sequence to the end of the settings file depending on how you code it.  So something to keep in mind.)

Once its in settings you only need to create a DataSourceComponent somewhere in the widget, that binds to that use Data source. something like this:

<DataSourceComponent widgetId={props.id} query={{ where: '1=1' }} useDataSource={props.useDataSources[0]}>{renderData}</DataSourceComponent>


Note if you want the user to be able to update the query's where clause that you'll have to get that data from controls on the widget, and update it in state to pass to this query to do the filtering.

Hopefully, that's enough to get you started.

0 Kudos
MK13
by
Frequent Contributor

@TimWestern thanks for the detailed explanation. It is very helpful for when I want the user to set the data source.

But what if I don't want the user to set the datasource. I just want to hard code the FeatureLayer's path into my code and set a query on it? The code below shows that I define a feature layer and add it to the map. After it's added to the map, I'd like to apply a filter on it.

 

const omLyr = new FeatureLayer({
    url: 'https://services5.arcgis.com/M4Fx5dTKFkYlecLT/arcgis/rest/services/MyServiceName/FeatureServer'
  })
  const addLayerHandler = (evt) => {
    evt.preventDefault()
    jmv.view.map.add(omLyr)
    //add code to filter the feature layer
  }

 

In the example that I linked in the original question, here's the code to apply a filter

 

const ds: FeatureLayerDataSource = dsManager.getDataSource(useDataSource.dataSourceId) as FeatureLayerDataSource;
const queryParams: SqlQueryParams = {
  where: `${props.config.filterField} LIKE '%${evt.target.value}%'`
};

// Filter the data source using updateQueryParams function.
ds.updateQueryParams(queryParams, props.id);

 

But the above code requires a FeatureLayerDataSource. How do I convert my FeatureLayer into a FeatureLayerDataSource?

0 Kudos
TimWestern
Frequent Contributor

Unless I'm misunderstanding, you are building a custom widget.

There are two UI interfaces that act as thee primary entry points for a custom widget.  

The first is the obvious one: widget.tsx (for me this is in a folder under the widget like src/runtime/widget.tsx)
This is displayed in the UI in the Dashboard editor, and in the location when the widget is active or present on screen when previewed or run.
-> IN your above example, you can in theory have a group of data sources loaded in a dropdown and select them.  That does not always make sense for the users, because they may not even know the data source names.

The second is the settings section.  The settings section becomes active in the right pane of the Exb dashboard editor when you make your widget active in the canvas.  Whether its in a panel, or a button clicked from a widget controller, if you have a settings.tsx file (usually under src/setting/setting.tsx file)  You might set up a setting to be selected.  It could be anything, a map, a data source, controls for dynamic text, or sizing that you want to use on your page (the sky, provided it passes sanitization for XSS is the limit)

here is an example widget for toggling layers that shows a settings.tsx for a map for example
https://github.com/Esri/arcgis-experience-builder-sdk-resources/blob/master/widgets/view-layers-togg...


The key difference between widget and setting files is that setting view is set at Design time in the edit dashboard, its not intended to be changeable by a user, but setup when the app or widget is deployed.  Meaning you can reuse a custom widget in other dashboards, provided you configure it with a similar setting or data source.   Because of that, your users won't know what's connecting to, but if there are other controls (I think you might be able to get map layer visibility for example) You could in theory use that to update the query and return a different set of data.)



On the second part.  Feature Layer Data Source, yeah this took me a while to get:

So for example.  one thing you can do is cast a DataSource as a FeatureLayerDataSource and if its an actual valid DataSource it can act as one.  I created this method to help me with checking if a given datasource index was loaded and if not wait and keep checking.

waitForDataSource = (layerIndex) => {
return new Promise((resolve) => {
const checkDataSource = () => {
const dataSource = this.props.useDataSources[layerIndex]
if (dataSource) {
const dataSourceId = dataSource.dataSourceId
const dsParcels = DataSourceManager.getInstance().getDataSource(dataSourceId) as FeatureLayerDataSource
if (dsParcels) {
resolve(dsParcels)
} else {
setTimeout(checkDataSource, 100) // Retry after 100ms
}
} else {
setTimeout(checkDataSource, 100) // Retry after 100ms
}
}
checkDataSource()
})
}



However, there are a couple of things to remember.  1. if the FeatureService you are doing is a pure RestService you may not be able to run queries directly on it with query, so I often grab the url from the dataSource and if the url exists, use that to do a restful query from experience builder to grab the feature(s) and their geometry/attributes and then do something with them:

This example is a little more plain, but might illustrate it another way.  I like to build a fall back in for calling the RestURL in case a FeatureService gets deployed without being part of a map as a FeatureLayer


const getObjectId = async(objectId, layerIndex) => {
layerIndex = dataSourceIndexes.SecondDataSource;

const dsManager = DataSourceManager.getInstance();
const useDataSource = useDataSources[layerIndex];

if (!useDataSource) {
throw new Error(`UseDataSource with index ${layerIndex} not found.`);
}

const dataSource = dsManager.getDataSource(useDataSource.dataSourceId) as FeatureLayerDataSource;
if (!dataSource) {
throw new Error(`DataSource with id ${useDataSource.dataSourceId} not found.`);
}

const dsFeatureLayer = DataSourceManager.getInstance().getDataSource(dataSourceId) as FeatureLayerDataSource;

if (!dsFeatureLayer) {
throw new Error('FeatureLayerDataSource not found');
}

const whereClause = `ObjectId = '${objectId}'`;
const outFields = '[*]';

// If the feature service has layer defined at this point, then we query it this way
if (dsFeatureLayer.layer) {
const query = dsFeatureLayer.layer.createQuery();
query.where = whereClause;
query.outFields = outFields;
query.returnGeometry = false;

const results = await dsFeatureLayer.layer.queryFeatures(query);
if (results.features.length > 0) {
return results.features[0].attributes as MyObject; // Where MyObject is a type or interface we defined somewhere.;
}
} else if (dsFeatureLayer.url) {
// Otherwise, we check if the URl is set, and cerate a new query formated for a REST Call, and add the token on the end
// (which can be gotten at the widget's top from this.props.token and passed into the method/component
const url = `${dsFeatureLayer.url}/query`;
const bodyParams = {
f: 'json',
where: whereClause,
outFields: '[*]',
returnGeometry: 'false',
token: token
};

// Format the URL Search Parametrs for the request
const body = new URLSearchParams();
for (const key in bodyParams) {
body.append(key, bodyParams[key]);
}

// Now this looks like a Post instead of a GET, I had expected this to be a GET Method.
// Note this is the async/await method for calling into an asynchronous endpoint (the other is .then/.catch read up on JavaScript Promises to know more)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});

// Once the response returns an OK we can then let the content be processed as JSON
if (response.ok) {
const result = await response.json();
if (result.features.length > 0) {
return result.features[0].attributes as MyObject; // Where MyObject is a type or interface we defined somewhere.
}
} else {
throw new Error(`Error querying features via REST API: ${response.statusText}`);
}
} else {
throw new Error('FeatureLayerDataSource.layer or URL not found');
}

throw new Error('MyObject not found');
};






EDIT:

Just realized you were hard coding the URL, you can certainly do that as well, it means every time you change host or the path of the service moves you'd have to update the widget in the code, but you can do that in theory.



I realize my examples aren't looking like Sql as much too, so there are some subtle differences.   but if the DataSourceComponent is tied to a source that helps manage a query, updating the state should trigger it to requery I think.


0 Kudos
MK13
by
Frequent Contributor

Thanks for the detailed code. I am stuck at the line below because it is utilising useDataSource which I am not using since I have a hard coded url for the feature layer. How do I generate a FeatureLayerDataSource from a hard coded FeatureLayer?

const dataSource = dsManager.getDataSource(useDataSource.dataSourceId) as FeatureLayerDataSource;

This is the code showing my hard coded feature layer which I'd like to filter:

const omLyr = new FeatureLayer({
    url: 'https://services5.arcgis.com/M4Fx5dTKFkYlecLT/arcgis/rest/services/MyServiceName/FeatureServer'
  })
  const addLayerHandler = (evt) => {
    evt.preventDefault()
    jmv.view.map.add(omLyr)
    //add code to filter the feature layer
  }

 

0 Kudos
TimWestern
Frequent Contributor

Yeah if you are hard coding the URL in the widget (it will be visible in the code when published.) Then you can probably skip to the section that talks about 

// if you already have the url, then just pay attention to where Url is used above and replace this:

const url = `${dsFeatureLayer.url}/query`;

wiuth this:
const myUrl = 'https://myhost.com/portal/path/whatever'
const url = `${myUrl }/query`;


and then pass it that way?


Which could look like this:

	so: 
	const myUrl = 'substitute your url here'
	const queryUrl = `${myUrl}/query`

    const featureLayer = new FeatureLayer({
      url: queryUrl
    })

    const query = featureLayer.createQuery()
    query.returnGeometry = true
    query.outFields = ['*']
    query.outSpatialReference = jimuMapView.view.spatialReference
	query.where = `FieldName ='${fieldNameValue}'`


    featureLayer.queryFeatures(query).then(result => {
      jimuMapView.view.container.style.cursor = 'wait'
      onQueryComplete(result)  // Where onQueryComplete is a method of what to do once it has completed.
      processSelection(result) // Where processSelection is a method that does some additional processing.
    }).catch(error => {
      setErrorMessage('An error occurred during the query.')      
      console.error(error)
    })
	
		(Note you might not have a set Error Mesage usestate 
	



Now it looks like you are trying to add the layer to the map? are you trying to create a new layer? Or are you just trying to query an existing one? I'm wondering if I misunderstood your goal.



0 Kudos
MK13
by
Frequent Contributor

This is my entire code below. I added in some of the code that you shared above. Basically, on the click of a button (AddLayer), I add a layer to the map. On the click of another button (FIlterLayer), I filter the layer. I've been able to add the filtering information you shared but when I tested, the map does not refresh to show the filtered layer. I am currently utilising alert in the OnQueryComplete method because I am unsure on how to have the map refresh to show the filtered layer. PS: I am a C# developer that is just starting out with javascript so please bear with me. I am a complete novice :).

import { React, type SqlQueryParams, type AllWidgetProps, DataSourceManager, type FeatureLayerDataSource } from 'jimu-core'
import { JimuMapViewComponent, type JimuMapView } from 'jimu-arcgis'
import FeatureLayer from 'esri/layers/FeatureLayer'

const { useState } = React //{} allow destructuring of this constant

const Widget = (props: AllWidgetProps<any>) => {
  const omLyr = new FeatureLayer({
    url: 'https://services5.arcgis.com/M4Fx5dTKFkYlecLTsdsdsd/arcgis/rest/services/OpenMarketSm/FeatureServer'
  })
  const [jmv, setJmv] = useState<JimuMapView>()
  const activeViewChangeHandler = (jimumapview: JimuMapView) => {
    if (jimumapview) {
      setJmv(jimumapview)
    }
  }
  const addLayerHandler = (evt) => {
    evt.preventDefault()
    jmv.view.map.add(omLyr)
  }
  const OnQueryComplete = (evt) => {
    alert(evt)
  }
  const filterLayerHandler = (evt) => {
    evt.preventDefault()
    const query = omLyr.createQuery()
    query.returnGeometry = true
    query.outFields = ['*']
    query.outSpatialReference = jmv.view.spatialReference
    query.where = "ServiceAreaType ='Comm. Recycling'"

    omLyr.queryFeatures(query).then(result => {
      jmv.view.container.style.cursor = 'wait'
      OnQueryComplete(result)
    }).catch(error => { alert(error) })
  }
  return (
      <div>
      <div>
  {props.useMapWidgetIds && props.useMapWidgetIds.length === 1 &&
        (<JimuMapViewComponent useMapWidgetId={props.useMapWidgetIds?.[0]} onActiveViewChange={activeViewChangeHandler}/>)}
      </div>
      <form onSubmit={addLayerHandler}>
        <button className='btn btn-primary'>
          Add Layer
        </button>
      </form>
      <form onSubmit={filterLayerHandler}>
        <button className='btn btn-primary'>
          Filter Layer
        </button>
      </form>
      </div>
  )
}

export default Widget
//activeViewChangeHandler is called once when the map is ready
// {/* ?. checks if object is null  */}

 

0 Kudos
TimWestern
Frequent Contributor

I don't have an example of adding a layer to a map handy I can reference.

(I started off with C# then brought in nodeJS and react btw)

I'm wondering if the issue here, is the map, may be a stand alone map you drag onto the platform that is tied to a specific data source, and the widget adds a layer to it.   That may happen on the server, but will it show up in the map, if its already pointing at a data source that its showing? I'm not sure.


But I wonder what that layer does with all the rerendering that react does.

From what I've been reading, 

Map -> Links to a map data source

Layer added to Map -> Applied to same map data source

But because the Out of the box map widget is tied to a data source, how does it know to re-render to pick up the change you just made?  There may be a concern where the Map may be connected, but it doesn't know to update is what I'm thinking.  I'm not sure what the solution to that is off hand.


I don't think you have much choice over what renders what in the app, I know widgets tend to be self contained in the data they see, unless its persisted to the platform data store (which I believe is redux, I haven't found a good way to manage that yet.)

Maybe someone else will have an idea.



0 Kudos
MK13
by
Frequent Contributor

I'll keep researching it. Thanks for your help regardless :).

0 Kudos