• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

terrestris / react-geo / 18933910017

30 Oct 2025 08:04AM UTC coverage: 67.733%. Remained the same
18933910017

push

github

web-flow
Merge pull request #4435 from terrestris/dependabot/npm_and_yarn/babel/preset-typescript-7.28.5

build(deps-dev): bump @babel/preset-typescript from 7.27.1 to 7.28.5

672 of 1082 branches covered (62.11%)

Branch coverage included in aggregate %.

1234 of 1732 relevant lines covered (71.25%)

13.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.73
/src/Panel/SearchResultsPanel/SearchResultsPanel.tsx
1
import './SearchResultsPanel.less';
2

3
import React, { ReactNode, useEffect, useState } from 'react';
4

5
import {
6
  Avatar,
7
  Collapse,
8
  CollapseProps,
9
  List
10
} from 'antd';
11

12
import _isEmpty from 'lodash/isEmpty';
13
import OlFeature from 'ol/Feature';
14
import BaseLayer from 'ol/layer/Base';
15
import OlLayerVector from 'ol/layer/Vector';
16
import OlSourceVector from 'ol/source/Vector';
17
import OlStyle from 'ol/style/Style';
18

19
import useMap from '@terrestris/react-util/dist/Hooks/useMap/useMap';
20

21

22
const Panel = Collapse.Panel;
1✔
23
const ListItem = List.Item;
1✔
24

25
export interface Category {
26
  title: string;
27
  /** Each feature is expected to have at least the properties `title` and `geometry` */
28
  features: OlFeature[];
29
  icon?: React.ReactNode | string;
30
}
31

32
interface SearchResultsPanelProps extends Partial<CollapseProps> {
33
  searchResults: Category[];
34
  numTotal: number;
35
  searchTerms: string[];
36
  /** Creator function that creates actions for each item */
37
  actionsCreator?: (item: any) => undefined | ReactNode[];
38
  /** A renderer function returning a prefix component for each list item */
39
  listPrefixRenderer?: (item: any) => undefined | JSX.Element;
40
  layerStyle?: undefined | OlStyle;
41
  onClick?: (item: any) => void;
42
}
43

44
const SearchResultsPanel = (props: SearchResultsPanelProps) => {
1✔
45
  const [highlightLayer, setHighlightLayer] = useState<OlLayerVector<OlSourceVector> | null>(null);
8✔
46
  const map = useMap();
8✔
47

48
  const {
49
    searchResults,
50
    numTotal,
51
    searchTerms,
52
    actionsCreator = () => undefined,
6✔
53
    listPrefixRenderer = () => undefined,
6✔
54
    layerStyle,
55
    onClick = item =>
8✔
56
      map?.getView().fit(item.feature.getGeometry(), { size: map.getSize() }),
×
57
    ...passThroughProps
58
  } = props;
8✔
59

60
  useEffect(() => {
8✔
61
    if (!map) {
4!
62
      return;
×
63
    }
64

65
    const layer = new OlLayerVector({
4✔
66
      source: new OlSourceVector()
67
    });
68

69
    if (layerStyle) {
4!
70
      layer.setStyle(layerStyle);
×
71
    }
72

73
    setHighlightLayer(layer);
4✔
74
    map.addLayer(layer);
4✔
75
  }, [layerStyle, map]);
76

77
  useEffect(() => {
8✔
78
    return () => {
8✔
79
      if (!map) {
8!
80
        return;
×
81
      }
82

83
      map.removeLayer(highlightLayer as BaseLayer);
8✔
84
    };
85
  }, [highlightLayer, map]);
86

87
  const highlightSearchTerms = (text: string) => {
8✔
88
    searchTerms.forEach(searchTerm => {
8✔
89
      const term = searchTerm.toLowerCase();
×
90
      if (term === '') {
×
91
        return;
×
92
      }
93
      let start = text.toLowerCase().indexOf(term);
×
94
      while (start >= 0) {
×
95
        const startPart = text.substring(0, start);
×
96
        const matchedPart = text.substring(start, start + term.length);
×
97
        const endPart = text.substring(start + term.length, text.length);
×
98
        text = `${startPart}<b>${matchedPart}</b>${endPart}`;
×
99
        start = text.toLowerCase().indexOf(term, start + 8);
×
100
      }
101
    });
102
    return text;
8✔
103
  };
104

105
  const onMouseOver = (feature: OlFeature) => {
8✔
106
    return () => {
8✔
107
      highlightLayer?.getSource()?.clear();
×
108
      highlightLayer?.getSource()?.addFeature(feature);
×
109
    };
110
  };
111

112
  /**
113
   * Renders content panel of related collapse element for each category and
114
   * its features.
115
   *
116
   * @param category The category to render
117
   * @param categoryIdx The idx of the category in the searchResults list.
118
   */
119
  const renderPanelForCategory = (category: Category, categoryIdx: number) => {
8✔
120
    const {
121
      features,
122
      title,
123
      icon
124
    } = category;
8✔
125

126
    if (!map) {
8!
127
      return <></>;
×
128
    }
129

130
    if (!features || _isEmpty(features)) {
8!
131
      return;
×
132
    }
133

134
    const header = (
135
      <div className="search-result-panel-header">
8✔
136
        <span>{`${title} (${features.length})`}</span>
137
        {
138
          icon &&
8!
139
          <Avatar
140
            className="search-option-avatar"
141
            src={icon}
142
          />
143
        }
144
      </div>
145
    );
146

147
    const categoryKey = getCategoryKey(category, categoryIdx);
8✔
148

149
    return (
8✔
150
      <Panel
151
        header={header}
152
        key={categoryKey}
153
      >
154
        <List
155
          size="small"
156
          dataSource={features.map((feat, idx) => {
157
            const text: string = highlightSearchTerms(feat.get('title'));
8✔
158
            return {
8✔
159
              text,
160
              idx,
161
              feature: feat
162
            };
163
          })}
164
          renderItem={(item: any) => (
165
            <ListItem
8✔
166
              className="result-list-item"
167
              key={item.idx}
168
              onMouseOver={onMouseOver(item.feature)}
169
              onMouseOut={() => highlightLayer?.getSource()?.clear()}
×
170
              onClick={() => onClick(item)}
×
171
              actions={actionsCreator(item)}
172
            >
173
              <div
174
                className="result-prefix"
175
              >
176
                {
177
                  listPrefixRenderer(item)
178
                }
179
              </div>
180
              <div
181
                className="result-text"
182
                dangerouslySetInnerHTML={{ __html: item.text }}
183
              />
184
            </ListItem>
185
          )}
186
        />
187
      </Panel>
188
    );
189
  };
190

191
  /**
192
   * Create a category key that is based on the category title and its position in searchResults.
193
   *
194
   * @param category The category to create the key for.
195
   * @param idx The position of the category in searchResults.
196
   * @returns The created key for the category.
197
   */
198
  const getCategoryKey = (category: Category, idx: number): string => {
8✔
199
    return `${category.title}-${idx}`;
16✔
200
  };
201

202
  if (numTotal === 0) {
8!
203
    return null;
×
204
  }
205

206
  return (
8✔
207
    <div className="search-result-div">
208
      <Collapse
209
        defaultActiveKey={searchResults[0] ? getCategoryKey(searchResults[0], 0) : undefined}
8!
210
        {...passThroughProps}
211
      >
212
        {
213
          searchResults.map(renderPanelForCategory)
214
        }
215
      </Collapse>
216
    </div>
217
  );
218
};
219

220
export default SearchResultsPanel;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc