Using query string parameter in custom search widget

197
2
04-16-2025 11:29 AM
DeanWilson
Occasional Contributor

Hi All!

I'm new to ExB development and coming from WAB originally.  This thing has been wracking my brain for a few days and I'm at my wits end here.  Not sure if it's just a don't know what you don't know thing, but it's still frustrating none-the-less.  

I'm trying to use a query string parameter that's passed through the url when I first load the app I'm building with ExB developer version 1.17.  In my custom widget I have a few different components.  The main search component service I have that does the actual searches works great when used within the context of the widget itself.  The steps I"m trying to get to are as follows:
Drop URL with query param in the browser > widget pulls query param (it's set to auto open on app start) > sets the search text using useState > called the handleSearch function (which is promise based)

 

Using a useEffect gives me a bunch of issues since you have to declare all of your dependencies, which includes the setSearchText, handleSearch function, and the like.   Here's the current widget.tsx:

/** @jsx jsx */
/** @jsxFrag React.Fragment */
import { React, type AllWidgetProps } from 'jimu-core'
import { jsx, css } from 'jimu-core'
import { type BufferSettings, BufferUnit, type IMConfig } from '../config'
import {
  Button, Card, CardBody, CardFooter, Dropdown, DropdownButton, DropdownItem, 
  DropdownMenu, Tab, Tabs, TextInput
} from 'jimu-ui'
import { SearchOutlined } from 'jimu-icons/outlined/editor/search'
import { MoreVerticalOutlined } from 'jimu-icons/outlined/application/more-vertical'
import ResultItem from './components/resultItem'
import ResultsTabTitle from './components/resultsTabTitle'
import { type JimuMapView, JimuMapViewComponent } from 'jimu-arcgis'
import { useMapService } from './services/map'
import { useSearchService } from './services/search'
import type Graphic from '@arcgis/core/Graphic'
import DrawTools from './components/drawTools'
import BufferControls from './components/bufferControls'

const { useState } = React

enum TabName {
  ByValue = 'byValue',
  ByShape = 'byShape',
  Results = 'results'
}

const Widget = (props: AllWidgetProps<IMConfig>) => {
  const { id, config } = props
  const [isLoading, setIsLoading] = useState<boolean>(false)
  const [error, setError] = useState<Error | null>(null)
  const [searchText, setSearchText] = useState('')
  const [activeTab, setActiveTab] = useState<TabName>(TabName.ByValue)
  const [jimuMapView, setJimuMapView] = useState<JimuMapView>(null)
  const [searchResults, setSearchResults] = useState<Graphic[]>([])
  const [searchResultsCount, setSearchResultsCount] = useState(0)
  const [bufferSettings, setBufferSettings] = useState<BufferSettings>({
    enabled: false,
    distance: 5,
    unit: BufferUnit.Feet
  })

  const mapService = useMapService(id)
  const searchService = useSearchService(config.selectedLayerId)

  const handleActiveViewChange = (jmv: JimuMapView) => {
    setJimuMapView(jmv)

    if (jmv) {
      searchService.initialize(jmv)
      mapService.initialize(jmv)
    }
  }

  const handleUpdateBufferSetting = (field: keyof BufferSettings, value: any) => {
    setBufferSettings({
      ...bufferSettings,
      [field]: value
    })
  }

  const handleSearch = async () => {
    if (!jimuMapView || !config.selectedLayerId || !searchText.trim()) {
      return
    }
    setIsLoading(true)
    setError(null)

    const { features, error } = await searchService.searchFeatures(
      searchText,
      config.searchFields
    )

    if (error) {
      setError(error)
      setIsLoading(false)
      return
    }

    setSearchResults(features)

    if (features.length > 0) {
      setSearchResultsCount(features.length)
      setActiveTab(TabName.Results)

      mapService.updateResultsLayer(features, `${config.title} Results`)
    }

    setIsLoading(false)
  }

  const handleSpatialSearch = async (graphic: Graphic) => {
    if (!jimuMapView || !config.selectedLayerId || !graphic) {
      return
    }
    setIsLoading(true)
    setError(null)

    const { features, error } = await searchService.searchFeaturesBySpatial(
      graphic.geometry,
      bufferSettings
    )

    if (error) {
      setError(error)
      setIsLoading(false)
      return
    }

    setSearchResults(features)

    if (features.length > 0) {
      setSearchResultsCount(features.length)
      setActiveTab(TabName.Results)

      mapService.updateResultsLayer(features, `${config.title} Results`)

      if (bufferSettings.enabled) {
        mapService.highlightBufferedSearchFeature(graphic)
      }
    }
    setIsLoading(false)
  }

  const handleClearSearch = () => {
    setSearchResults([])
    setSearchResultsCount(0)
    setSearchText('')
    setError(null)

    mapService.clearResults()

    setActiveTab(TabName.ByValue)
  }

  const handlePressEnter = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (searchText.trim()) {
      handleSearch()
    }
  }

  const handleResultItemClick = (item: Graphic) => {
    if (item) {
      mapService.highlightFeature(item)
    }
  }

  const handleSearchOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setSearchText(event.target.value)
    if (error) setError(null)
  }

  const getSearchableFieldsText = () => {
    if (!config.searchFields || config.searchFields.length === 0) {
      return 'Search by keyword'
    }
    
    return config.searchFields.reduce((text, field, index, array) => {
      const fieldAlias = field.alias || field.fieldName
      
      if (index === 0) {
        return `Search by ${fieldAlias}`
      } else if (index === array.length - 1) {
        return `${text}, or ${fieldAlias}`
      } else {
        return `${text}, ${fieldAlias}`
      }
    }, '')
  }

  return (
    <div className="widget-wc-search" css={css`
      height: 100%;
      display: flex;
      flex-direction: column;
    `}>
      <Tabs
        className='h-100'
        defaultValue='byValue'
        value={activeTab}
        fill
        onChange={(tabId: string) => {
          if (Object.values(TabName).includes(tabId as TabName)) {
            setActiveTab(tabId as TabName) 
          }
        }}
        type='pills'
      >
        <Tab
          id={TabName.ByValue}
          title='By Value'
        >
          <Card>
            <CardBody>
            {error && (
              <div className="error-message" css={css`
                color: var(--sys-color-error-main);
                margin-bottom: 10px;
                padding: 8px;
                background-color: var(--sys-color-error-light);
                border-radius: 4px;
              `}>
                <p>Error: {error.message}</p>
              </div>
            )}
              <p>{getSearchableFieldsText()}</p>
              <TextInput
                placeholder='Enter Account Number, Parcel, Etc.'
                onPressEnter={handlePressEnter}
                disabled={isLoading}
                value={searchText}
                onChange={handleSearchOnChange}
                prefix={<SearchOutlined size="s" />}
              />
            </CardBody>
            <CardFooter
              css={css`
                display: flex;
                justify-content: space-between;
              `}
            >
              <Button
                onClick={handleClearSearch}
                disabled={!searchText.trim()}
                size='default'
              >
                Clear
              </Button>
              <Button
                onClick={handleSearch}
                disabled={isLoading || !searchText.trim()}
                size='default'
                css={css`${(isLoading) ? 'background-color: var(--ref-palette-neutral-300) !important;' : ''}`}
              >
                {(isLoading) ? 'Searching...' : 'Search'}
              </Button>
            </CardFooter>
          </Card>
        </Tab>

        <Tab
          id={TabName.ByShape}
          title='By Shape'
        >
          <Card>
            <CardBody>
              {error && (
                <div className="error-message" css={css`
                  color: var(--sys-color-error-main);
                  margin-bottom: 10px;
                  padding: 8px;
                  background-color: var(--sys-color-error-light);
                  border-radius: 4px;
                `}>
                  <p>Error: {error.message}</p>
                </div>
              )}
                <p>Select features by</p>
                <DrawTools 
                  jimuMapView={jimuMapView}
                  onGraphicCreated={handleSpatialSearch}
                />
                <BufferControls
                  settings={bufferSettings}
                  onUpdateSetting={handleUpdateBufferSetting}
                />
            </CardBody>
          </Card>
        </Tab>

        <Tab
          id={TabName.Results}
          title={(<ResultsTabTitle recordCount={searchResultsCount} />)}
        >
          <div>
            <div
              className='d-flex justify-content-end pt-1 pb-1 sticky-top bg-overlay'
            >
              <Dropdown
                direction='down'
              >
                <DropdownButton
                  arrow={false}
                  icon
                  size='default'
                  css={css`
                    border: none !important;
                  `}
                >
                  <MoreVerticalOutlined />
                </DropdownButton>
                <DropdownMenu>
                  <DropdownItem
                    onClick={handleClearSearch}
                  >
                    Clear Results
                  </DropdownItem>
                </DropdownMenu>
              </Dropdown>
            </div>
            <>            
              {
                searchResults.map((feature, index) => {
                  return (
                    <ResultItem 
                      key={feature.attributes.OBJECTID}
                      feature={feature}
                      displayFields={config.displayFields}
                      summaryConfig={{
                        showSummaryLink: config.showSummaryLink,
                        summaryLinkLabel: config.summaryLinkLabel,
                        summaryUrlTemplate: config.summaryUrlTemplate,
                        summaryLinkFieldName: config.summaryLinkFieldName
                      }}  
                      onItemClick={() => { handleResultItemClick(feature) }}
                    />
                  )
                })
              }
            </>
          </div>
        </Tab>
      </Tabs>
      <JimuMapViewComponent
        useMapWidgetId={config.useMapWidgetIds?.[0]}
        onActiveViewChange={handleActiveViewChange}
      />
    </div>
  )
}

export default Widget

 

I still have some refactoring to do but thats the gist of it.  Not sure where to go with this and Claude.ai keeps lying to me like AI usually does. lol

 

Thanks in advance!

 

- Dean Wilson

0 Kudos
2 Replies
DeanWilson
Occasional Contributor

Bumping this!

0 Kudos
TimWestern
MVP

I don't understand your useEffect comment.

Generally there are only two ways to load data at initial load.  either you use a useEffect for it, or you use methods from the componen tmodel like ComponentDidMount.

In theory you could grab some things locally in the render method but if you set state you risk an infinite rendering loop when render keeps updating.

One option is this:

import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'

// You could add this:
const location = useLocation()

useEffect(() => {
  const params = new URLSearchParams(location.search)
  const searchParam = params.get('search')

  if (searchParam) {
    setSearchText(searchParam)
    handleSearch()
  }
}, []) // empty dependency array to run only once on mount

 

Explanation:

  • useLocation() gives you access to the current URL in ExB.

  • URLSearchParams parses the query string (?search=value).

  • If the search param exists, we:

    • Set the state via setSearchText

    • Immediately run handleSearch()


      If you want the widget to update the query string and take that into account it might be a bit more complex:

import { useLocation, useNavigate } from 'react-router-dom'


const location = useLocation()
const navigate = useNavigate()

const updateQueryParam = (key: string, value: string) => {
  const params = new URLSearchParams(location.search)
  if (value) {
    params.set(key, value)
  } else {
    params.delete(key)
  }

  navigate({
    pathname: location.pathname,
    search: params.toString()
  }, { replace: true }) // use replace: true to avoid adding new history entries
}


// your handle Search method might begin to look liket his:
const handleSearch = async () => {
  if (!jimuMapView || !config.selectedLayerId || !searchText.trim()) return

  updateQueryParam('search', searchText)

  // continue with existing search logic...
}

 


Caution though, if you're changing query params and want the widget to react to those changes, you would need to update the useEffect logic suggested earlier to respond to location.search changes:

useEffect(() => {
  const params = new URLSearchParams(location.search)
  const searchParam = params.get('search')

  if (searchParam && searchParam !== searchText) {
    setSearchText(searchParam)
    handleSearch()
  }
}, [location.search])


Third possibility useRef hook:  a useRef or local boolean flag to ensure initialization logic only runs once after first render.




const initializedRef = useRef(false)

if (!initializedRef.current) {
  const params = new URLSearchParams(location.search)
  const searchParam = params.get('search')

  if (searchParam && searchParam !== searchText) {
    setSearchText(searchParam)
    handleSearch()
  }

  initializedRef.current = true
}

 

If you changed it to a class based component you could do something like this in componentDidMount()

componentDidMount() {
  const params = new URLSearchParams(window.location.search)
  const searchParam = params.get('search')
  if (searchParam) {
    this.setState({ searchText: searchParam }, this.handleSearch)
  }
}


Ultimately if you are insistent on  not using useEffect this is likely the best compromise:

const initializedRef = useRef(false)

if (!initializedRef.current) {
  const params = new URLSearchParams(location.search)
  const searchParam = params.get('search')

  if (searchParam && searchParam !== searchText) {
    setSearchText(searchParam)
    handleSearch()
  }

  initializedRef.current = true
}


It's clean, readable, and safe — and works once, just like a componentDidMount.

(In theory you could encapsulate this into your own useInitOnce hook, but that's a bit more advanced.

Does this help?

0 Kudos