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

terrestris / react-geo / 18274503963

06 Oct 2025 08:18AM UTC coverage: 63.354%. Remained the same
18274503963

push

github

web-flow
Merge pull request #4396 from terrestris/dependabot/npm_and_yarn/commitlint/cli-20.1.0

build(deps-dev): bump @commitlint/cli from 19.8.1 to 20.1.0

597 of 1040 branches covered (57.4%)

Branch coverage included in aggregate %.

1137 of 1697 relevant lines covered (67.0%)

11.77 hits per line

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

50.41
/src/Grid/FeatureGrid/FeatureGrid.tsx
1
import React, { createContext, Key, useCallback, useContext, useEffect, useState } from 'react';
2

3
import {
4
  closestCenter, DndContext, DragEndEvent, DragOverEvent, DragOverlay, PointerSensor,
5
  UniqueIdentifier, useSensor, useSensors
6
} from '@dnd-kit/core';
7
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
8
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable';
9

10
import { Table } from 'antd';
11
import { AnyObject } from 'antd/lib/_util/type';
12
import { ColumnsType, ColumnType, TableProps } from 'antd/lib/table';
13
import _has from 'lodash/has';
14
import _isFunction from 'lodash/isFunction';
15
import _isNil from 'lodash/isNil';
16
import _kebabCase from 'lodash/kebabCase';
17
import { getUid } from 'ol';
18
import OlFeature from 'ol/Feature';
19
import OlGeometry from 'ol/geom/Geometry';
20
import OlGeometryCollection from 'ol/geom/GeometryCollection';
21
import OlLayerBase from 'ol/layer/Base';
22
import OlLayerVector from 'ol/layer/Vector';
23
import OlMapBrowserEvent from 'ol/MapBrowserEvent';
24
import RenderFeature from 'ol/render/Feature';
25
import OlSourceVector from 'ol/source/Vector';
26

27
import useMap from '@terrestris/react-util/dist/Hooks/useMap/useMap';
28
import useOlLayer from '@terrestris/react-util/dist/Hooks/useOlLayer/useOlLayer';
29

30
import { CSS_PREFIX } from '../../constants';
31
import {
32
  defaultFeatureGridLayerName,
33
  defaultFeatureStyle,
34
  defaultHighlightStyle,
35
  defaultSelectStyle,
36
  RgCommonGridProps
37
} from '../commonGrid';
38

39
interface HeaderCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
40
  id: string;
41
}
42

43
interface BodyCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
44
  id: string;
45
}
46

47
interface DragIndexState {
48
  active: UniqueIdentifier;
49
  over: UniqueIdentifier | undefined;
50
  direction?: 'left' | 'right';
51
}
52

53
interface OwnProps<RecordType = AnyObject> {
54
  /**
55
   * Custom column definitions to apply to the given column (mapping via key).
56
   * See https://ant.design/components/table/#Column.
57
   *
58
   * To control the columns manually, pass the `columns` prop to the table.
59
   */
60
  columnDefs?: ColumnsType<RecordType>;
61
  /**
62
   * When active the order of the columns can be changed dynamically using drag & drop.
63
   */
64
  draggableColumns?: boolean;
65
  /**
66
   * Array of dataIndex names to filter
67
   */
68
  attributeFilter?: string[];
69
  onRowSelectionChange?: (selectedRowKeys: (number | string | bigint)[],
70
    selectedFeatures: OlFeature<OlGeometry>[]) => void;
71
}
72

73
export type FeatureGridProps<T extends AnyObject = AnyObject> = OwnProps<T> & RgCommonGridProps<T> & TableProps<T>;
74

75
const defaultClassName = `${CSS_PREFIX}feature-grid`;
1✔
76

77
const defaultRowClassName = `${CSS_PREFIX}feature-grid-row`;
1✔
78

79
const rowKeyClassNamePrefix = 'row-key-';
1✔
80

81
const cellRowHoverClassName = 'ant-table-cell-row-hover';
1✔
82

83
const DragIndexContext = createContext<DragIndexState>({ active: -1, over: -1 });
1✔
84

85
const dragActiveStyle = (dragState: DragIndexState, id: string) => {
1✔
86
  const { active, over, direction } = dragState;
×
87
  // drag active style
88
  let style: React.CSSProperties = {};
×
89
  if (active && active === id) {
×
90
    style = { backgroundColor: 'gray', opacity: 0.5 };
×
91
  }
92
  // dragover dashed style
93
  else if (over && id === over && active !== over) {
×
94
    style =
×
95
      direction === 'right'
×
96
        ? { borderRight: '1px dashed gray' }
97
        : { borderLeft: '1px dashed gray' };
98
  }
99
  return style;
×
100
};
101

102
const TableBodyCell: React.FC<BodyCellProps> = (props) => {
1✔
103
  const dragState = useContext<DragIndexState>(DragIndexContext);
×
104
  return <td {...props} style={{ ...props.style, ...dragActiveStyle(dragState, props.id) }} />;
×
105
};
106

107
const TableHeaderCell: React.FC<HeaderCellProps> = (props) => {
1✔
108
  const dragState = useContext(DragIndexContext);
×
109
  const { attributes, listeners, setNodeRef, isDragging } = useSortable({ id: props.id });
×
110
  const style: React.CSSProperties = {
×
111
    ...props.style,
112
    cursor: 'move',
113
    ...(isDragging ? { position: 'relative', zIndex: 9999, userSelect: 'none' } : {}),
×
114
    ...dragActiveStyle(dragState, props.id),
115
  };
116
  return <th {...props} ref={setNodeRef} style={style} {...attributes} {...listeners} />;
×
117
};
118

119
export const FeatureGrid = <T extends AnyObject = AnyObject,>({
1✔
120
  attributeBlacklist,
121
  attributeFilter,
122
  children,
123
  className,
124
  columnDefs,
125
  draggableColumns = false,
33✔
126
  featureStyle = defaultFeatureStyle,
30✔
127
  features,
128
  highlightStyle = defaultHighlightStyle,
30✔
129
  keyFunction = getUid,
33✔
130
  layerName = defaultFeatureGridLayerName,
33✔
131
  onRowClick: onRowClickProp,
132
  onRowMouseOut: onRowMouseOutProp,
133
  onRowMouseOver: onRowMouseOverProp,
134
  onRowSelectionChange,
135
  rowClassName,
136
  selectStyle = defaultSelectStyle,
27✔
137
  selectable = false,
27✔
138
  zoomToExtent = false,
33✔
139
  ...passThroughProps
140
}: FeatureGridProps<T>): React.ReactElement | null => {
141

142
  type InternalTableRecord = (T & { key?: string });
143
  type SortableItemId = UniqueIdentifier | { id: UniqueIdentifier };
144

145
  const initialColumns: ColumnType<T>[] = [];
33✔
146

147
  const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
33✔
148
  const [dragIndex, setDragIndex] = useState<DragIndexState>({ active: -1, over: -1 });
33✔
149
  const [featureColumns, setFeatureColumns] = useState<ColumnType<T>[]>(initialColumns);
33✔
150
  const [columnDefinition, setColumnDefinition] = useState<ColumnsType<T>>([]);
33✔
151

152
  const sensors = useSensors(
33✔
153
    useSensor(PointerSensor, {
154
      activationConstraint: {
155
        distance: 1
156
      }
157
    })
158
  );
159

160
  const map = useMap();
33✔
161

162
  const layer = useOlLayer(() => new OlLayerVector({
33✔
163
    properties: {
164
      name: layerName
165
    },
166
    source: new OlSourceVector({
167
      features: features
168
    }),
169
    style: featureStyle
170
  }), []);
171

172
  /**
173
   * Selects the selected feature in the map and in the grid.
174
   */
175
  const onMapSingleClick = useCallback((olEvt: OlMapBrowserEvent<PointerEvent | KeyboardEvent | WheelEvent>) => {
33✔
176
    if (!map) {
×
177
      return;
×
178
    }
179

180
    const selectedFeatures = (map.getFeaturesAtPixel(olEvt.pixel, {
×
181
      layerFilter: (layerCand: OlLayerBase) => layerCand === layer
×
182
    }) || []) as OlFeature<OlGeometry>[];
183

184
    let rowKeys = [...selectedRowKeys];
×
185

186
    selectedFeatures.forEach(selectedFeature => {
×
187
      const key = keyFunction(selectedFeature);
×
188
      if (rowKeys.includes(key)) {
×
189
        rowKeys = rowKeys.filter(rowKey => rowKey !== key);
×
190
        selectedFeature.setStyle(undefined);
×
191
      } else {
192
        rowKeys.push(key);
×
193
        selectedFeature.setStyle(selectStyle);
×
194
      }
195
    });
196

197
    setSelectedRowKeys(rowKeys);
×
198
  }, [keyFunction, layer, map, selectStyle, selectedRowKeys]);
199

200

201
  /**
202
   * Highlights the feature beneath the cursor on the map and in the grid.
203
   */
204
  const onMapPointerMove = useCallback((olEvt: OlMapBrowserEvent<PointerEvent | KeyboardEvent | WheelEvent>) => {
33✔
205
    if (!map) {
×
206
      return;
×
207
    }
208

209
    const selectedFeatures = map.getFeaturesAtPixel(olEvt.pixel, {
×
210
      layerFilter: (layerCand: OlLayerBase) => layerCand === layer
×
211
    }) || [];
212

213
    features?.forEach(feature => {
×
214
      const key = _kebabCase(keyFunction(feature));
×
215
      const sel = `.${defaultRowClassName}.${rowKeyClassNamePrefix}${key} > td`;
×
216
      const els = document.querySelectorAll(sel);
×
217
      els.forEach(el => el.classList.remove(cellRowHoverClassName));
×
218

219
      if (selectedRowKeys.includes(key)) {
×
220
        feature.setStyle(selectStyle);
×
221
      } else {
222
        feature.setStyle(undefined);
×
223
      }
224
    });
225

226
    selectedFeatures.forEach((feature: OlFeature<OlGeometry> | RenderFeature) => {
×
227
      if (feature instanceof RenderFeature) {
×
228
        return;
×
229
      }
230

231
      const key = _kebabCase(keyFunction(feature));
×
232
      const sel = `.${defaultRowClassName}.${rowKeyClassNamePrefix}${key} > td`;
×
233
      const els = document.querySelectorAll(sel);
×
234
      els.forEach(el => el.classList.add(cellRowHoverClassName));
×
235

236
      feature.setStyle(highlightStyle);
×
237
    });
238
  }, [features, highlightStyle, keyFunction, layer, map, selectStyle, selectedRowKeys]);
239

240
  /**
241
   * Fits the map's view to the extent of the passed features.
242
   */
243
  const zoomToFeatures = useCallback((feats: OlFeature<OlGeometry>[]) => {
33✔
244
    if (!map) {
1!
245
      return;
×
246
    }
247

248
    const featGeometries = feats
1✔
249
      .map(f => f.getGeometry())
1✔
250
      .filter((f): f is OlGeometry => !_isNil(f));
1✔
251

252
    if (featGeometries.length > 0) {
1!
253
      const geomCollection = new OlGeometryCollection(featGeometries);
1✔
254
      map.getView().fit(geomCollection.getExtent());
1✔
255
    }
256
  }, [map]);
257

258
  useEffect(() => {
33✔
259
    if (!map) {
22✔
260
      return;
1✔
261
    }
262

263
    map.on('pointermove', onMapPointerMove);
21✔
264

265
    if (selectable) {
21✔
266
      map.on('singleclick', onMapSingleClick);
5✔
267
    }
268

269
    if (zoomToExtent && features) {
21!
270
      zoomToFeatures(features);
×
271
    }
272

273
    return () => {
21✔
274
      map.un('pointermove', onMapPointerMove);
21✔
275

276
      if (selectable) {
21✔
277
        map.un('singleclick', onMapSingleClick);
5✔
278
      }
279
    };
280
  }, [features, map, onMapPointerMove, onMapSingleClick, selectable, zoomToExtent, zoomToFeatures]);
281

282
  useEffect(() => {
33✔
283
    layer?.getSource()?.clear();
19✔
284
    if (!features) {
19!
285
      return;
×
286
    }
287

288
    layer?.getSource()?.addFeatures(features);
19✔
289

290
    if (zoomToExtent) {
19!
291
      zoomToFeatures(features);
×
292
    }
293
  }, [features, layer, zoomToExtent, zoomToFeatures]);
294

295
  useEffect(() => {
33✔
296
    if (!map) {
22✔
297
      return;
1✔
298
    }
299

300
    if (selectable) {
21✔
301
      map.on('singleclick', onMapSingleClick);
5✔
302
    } else {
303
      map.un('singleclick', onMapSingleClick);
16✔
304
    }
305
  }, [map, onMapSingleClick, selectable]);
306

307
  /**
308
   * Returns the column definitions out of the attributes of the first
309
   * given feature.
310
  */
311
  useEffect(() => {
33✔
312
    const colDefs: ColumnsType<T> = [];
10✔
313
    if (!features || features.length < 1) {
10!
314
      return;
×
315
    }
316

317
    const feature = features[0];
10✔
318
    const props = feature?.getProperties();
10✔
319

320
    if (!props) {
10!
321
      return;
×
322
    }
323

324
    let filter = attributeFilter;
10✔
325

326
    if (!filter) {
10!
327
      filter = feature.getKeys().filter((attrName: string) => attrName !== 'geometry');
30✔
328
    }
329

330
    for (const key of filter) {
10✔
331
      if (!_has(props, key)) {
20!
332
        continue;
×
333
      }
334

335
      if (attributeBlacklist?.includes(key)) {
20✔
336
        continue;
1✔
337
      }
338

339
      if (props[key] instanceof OlGeometry) {
19!
340
        continue;
×
341
      }
342

343
      colDefs.push({
19✔
344
        title: key,
345
        dataIndex: key,
346
        key: key,
347
        ...columnDefs?.find(col => (col as ColumnType<InternalTableRecord>).dataIndex === key)
2✔
348
      });
349
    }
350

351
    setColumnDefinition(colDefs);
10✔
352
  }, [columnDefs, features, attributeFilter, attributeBlacklist]);
353

354
  useEffect(() => {
33✔
355
    const colDefs = columnDefinition.map((column, i) => ({
20✔
356
      ...column,
357
      key: `${i}`,
358
      onHeaderCell: () => ({ id: `${i}` }),
25✔
359
      onCell: () => ({ id: `${i}` })
75✔
360
    }));
361
    setFeatureColumns(colDefs);
20✔
362
  }, [columnDefinition]);
363

364
  /**
365
   * Returns the table row data from all the given features.
366
  */
367
  const getTableData = useCallback((): InternalTableRecord[] => {
33✔
368

369
    if (!features) {
43!
370
      return [];
×
371
    }
372

373
    return features.map(feature => {
43✔
374
      const properties = feature.getProperties();
129✔
375
      const filtered: typeof properties = Object.keys(properties)
129✔
376
        .filter(key => !(properties[key] instanceof OlGeometry))
387✔
377
        .reduce((obj: Record<string, any>, key) => {
378
          obj[key] = properties[key];
258✔
379
          return obj;
258✔
380
        }, {});
381

382
      return {
129✔
383
        key: keyFunction(feature),
384
        ...filtered
385
      } as InternalTableRecord;
386
    });
387
  }, [features, keyFunction]);
388

389
  useEffect(() => {
33✔
390
    getTableData();
10✔
391
  }, [getTableData]);
392

393
  /**
394
   * Returns the correspondig feature for the given table row key.
395
   */
396
  const getFeatureFromRowKey = (key: number | string | bigint): OlFeature<OlGeometry> | null => {
33✔
397

398
    if (!features) {
12!
399
      return null;
×
400
    }
401

402
    const feature = features.filter(f => keyFunction(f) === key);
36✔
403

404
    return feature[0];
12✔
405
  };
406

407
  /**
408
   * Called on row click and zooms the corresponding feature's extent.
409
   */
410
  const onRowClick = (row: InternalTableRecord) => {
33✔
411
    if (!row.key) {
1!
412
      return;
×
413
    }
414

415
    const feature = getFeatureFromRowKey(row.key);
1✔
416

417
    if (!feature) {
1!
418
      return;
×
419
    }
420

421
    if (_isFunction(onRowClickProp)) {
1!
422
      onRowClickProp(row, feature);
×
423
    }
424

425
    zoomToFeatures([feature]);
1✔
426
  };
427

428
  /**
429
   * Called on row mouseover and hightlights the corresponding feature's
430
   * geometry.
431
   */
432
  const onRowMouseOver = (row: InternalTableRecord) => {
33✔
433
    if (!row.key) {
5!
434
      return;
×
435
    }
436

437
    const feature = getFeatureFromRowKey(row.key);
5✔
438

439
    if (!feature) {
5!
440
      return;
×
441
    }
442

443
    if (_isFunction(onRowMouseOverProp)) {
5!
444
      onRowMouseOverProp(row, feature);
×
445
    }
446

447
    highlightFeatures([feature]);
5✔
448
  };
449

450
  /**
451
   * Called on mouseout and un-hightlights any highlighted feature.
452
   */
453
  const onRowMouseOut = (row: InternalTableRecord) => {
33✔
454
    if (!row.key) {
×
455
      return;
×
456
    }
457

458
    const feature = getFeatureFromRowKey(row.key);
×
459

460
    if (!feature) {
×
461
      return;
×
462
    }
463

464
    if (_isFunction(onRowMouseOutProp)) {
×
465
      onRowMouseOutProp(row, feature);
×
466
    }
467

468
    unhighlightFeatures([feature]);
×
469
  };
470

471
  /**
472
   * Highlights the given features in the map.
473
   */
474
  const highlightFeatures = (feats: OlFeature<OlGeometry>[]) => {
33✔
475
    if (!map) {
5!
476
      return;
×
477
    }
478

479
    feats.forEach(feature => feature.setStyle(highlightStyle));
5✔
480
  };
481

482
  /**
483
   * Unhighlights the given features in the map.
484
   */
485
  const unhighlightFeatures = (feats: OlFeature<OlGeometry>[]) => {
33✔
486
    if (!map) {
×
487
      return;
×
488
    }
489

490
    feats.forEach(feature => {
×
491
      const key = keyFunction(feature);
×
492
      if (selectedRowKeys.includes(key)) {
×
493
        feature.setStyle(selectStyle);
×
494
      } else {
495
        feature.setStyle(undefined);
×
496
      }
497
    });
498
  };
499

500
  /**
501
   * Sets the select style to the given features in the map.
502
   *
503
   * @param feats
504
   */
505
  const selectFeatures = (feats: OlFeature<OlGeometry>[]) => {
33✔
506
    if (!map) {
3!
507
      return;
×
508
    }
509

510
    feats.forEach(feat => feat.setStyle(selectStyle));
6✔
511
  };
512

513
  /**
514
   * Resets the style of all features.
515
   */
516
  const resetFeatureStyles = () => {
33✔
517
    if (!map) {
3!
518
      return;
×
519
    }
520

521
    features?.forEach(feature => feature.setStyle(undefined));
9✔
522
  };
523

524
  /**
525
   * Called if the selection changes.
526
   */
527
  const onSelectChange = (rowKeys: Key[]) => {
33✔
528
    const selectedFeatures = rowKeys
3✔
529
      .map(key => getFeatureFromRowKey(key))
6✔
530
      .filter(feat => feat) as OlFeature<OlGeometry>[];
6✔
531

532
    if (selectedFeatures.length === 0 ) {
3!
533
      return;
×
534
    }
535

536
    if (_isFunction(onRowSelectionChange)) {
3!
537
      onRowSelectionChange(rowKeys, selectedFeatures);
×
538
    }
539

540
    resetFeatureStyles();
3✔
541
    selectFeatures(selectedFeatures);
3✔
542
    setSelectedRowKeys(rowKeys);
3✔
543
  };
544

545
  const rowSelection = {
33✔
546
    selectedRowKeys,
547
    onChange: onSelectChange
548
  };
549

550
  const finalClassName = className
33!
551
    ? `${className} ${defaultClassName}`
552
    : defaultClassName;
553

554
  let rowClassNameFn: (record: InternalTableRecord) => string;
555
  if (_isFunction(rowClassName)) {
33!
556
    const rwcFn = rowClassName as ((r: InternalTableRecord) => string);
×
557
    rowClassNameFn = record => `${defaultRowClassName} ${rwcFn(record)}`;
×
558
  } else {
559
    const finalRowClassName = rowClassName
33!
560
      ? `${rowClassName} ${defaultRowClassName}`
561
      : defaultRowClassName;
562
    rowClassNameFn = record => `${finalRowClassName} ${rowKeyClassNamePrefix}${_kebabCase(record.key)}`;
99✔
563
  }
564

565
  const onDragEnd = ({ active, over }: DragEndEvent) => {
33✔
566
    if (active.id !== over?.id) {
×
567
      setFeatureColumns((prevState) => {
×
568
        const activeIndex = prevState.findIndex((i) => i.key === active?.id);
×
569
        const overIndex = prevState.findIndex((i) => i.key === over?.id);
×
570
        return arrayMove(prevState, activeIndex, overIndex);
×
571
      });
572
    }
573
    setDragIndex({ active: -1, over: -1 });
×
574
  };
575

576
  const onDragOver = ({ active, over }: DragOverEvent) => {
33✔
577
    const activeIndex = featureColumns.findIndex((i) => i.key === active.id);
×
578
    const overIndex = featureColumns.findIndex((i) => i.key === over?.id);
×
579
    setDragIndex({
×
580
      active: active.id,
581
      over: over?.id,
582
      direction: overIndex > activeIndex ? 'right' : 'left'
×
583
    });
584
  };
585

586
  const convertKeysToIdentifiers = (keys: (Key | undefined)[]): SortableItemId[] => {
33✔
587
    return keys.map(key => {
×
588
      if (key === undefined) {
×
589
        return { id: 'defaultId' };
×
590
      } else if (typeof key === 'bigint') {
×
591
        return { id: key.toString() };
×
592
      } else if (typeof key === 'number' || typeof key === 'string') {
×
593
        return { id: key };
×
594
      } else {
595
        return key;
×
596
      }
597
    });
598
  };
599

600
  const draggableComponents = {
33✔
601
    header: { cell: TableHeaderCell },
602
    body: { cell: TableBodyCell }
603
  };
604

605
  const table = (
606
    <Table
33✔
607
      className={finalClassName}
608
      columns={featureColumns}
609
      dataSource={getTableData()}
610
      onRow={(record) => ({
99✔
611
        onClick: () => onRowClick(record),
1✔
612
        onMouseOver: () => onRowMouseOver(record),
5✔
613
        onMouseOut: () => onRowMouseOut(record)
×
614
      })}
615
      rowClassName={rowClassNameFn}
616
      rowSelection={selectable ? rowSelection : undefined}
33✔
617
      components={draggableColumns ? draggableComponents : undefined}
33!
618
      {...passThroughProps}
619
    >
620
      {children}
621
    </Table>
622
  );
623

624
  return draggableColumns ? (
33!
625
    <DndContext
626
      sensors={sensors}
627
      modifiers={[restrictToHorizontalAxis]}
628
      onDragEnd={onDragEnd}
629
      onDragOver={onDragOver}
630
      collisionDetection={closestCenter}
631
    >
632
      <SortableContext items={convertKeysToIdentifiers(featureColumns.map((i) => i.key))}
×
633
        strategy={horizontalListSortingStrategy}
634
      >
635
        <DragIndexContext.Provider value={dragIndex}>
636
          {table}
637
        </DragIndexContext.Provider>
638
      </SortableContext>
639
      <DragOverlay>
640
        <th style={{ backgroundColor: 'gray', padding: 16 }}>
641
          {featureColumns[featureColumns.findIndex((i) => i.key === dragIndex.active)]?.title as React.ReactNode}
×
642
        </th>
643
      </DragOverlay>
644
    </DndContext>
645
  ) : (
646
    table
647
  );
648
};
649

650
export default FeatureGrid;
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