From f2480e4b4f61f1601da5e70538a498da78d034f5 Mon Sep 17 00:00:00 2001 From: Shan He Date: Thu, 5 Jun 2025 19:55:39 -0700 Subject: [PATCH] wip Signed-off-by: Shan He --- src/components/src/effects/effect-panel.tsx | 7 +- .../src/modals/add-map-style-modal.tsx | 358 +++++++++--------- .../src/modals/data-table-modal.tsx | 255 +++++++------ .../src/modals/export-data-modal.tsx | 234 ++++++------ src/components/src/notification-panel.tsx | 49 +-- .../notification-panel/notification-item.tsx | 102 +++-- 6 files changed, 493 insertions(+), 512 deletions(-) diff --git a/src/components/src/effects/effect-panel.tsx b/src/components/src/effects/effect-panel.tsx index dcbe82c24f..b90ab4b05e 100644 --- a/src/components/src/effects/effect-panel.tsx +++ b/src/components/src/effects/effect-panel.tsx @@ -7,7 +7,7 @@ import classnames from 'classnames'; import PropTypes from 'prop-types'; import {dataTestIds, LIGHT_AND_SHADOW_EFFECT} from '@kepler.gl/constants'; -import {removeEffect, updateEffect} from '@kepler.gl/actions'; +import {ActionHandler, removeEffect, updateEffect} from '@kepler.gl/actions'; import {Effect} from '@kepler.gl/types'; import EffectPanelHeaderFactory from './effect-panel-header'; @@ -18,8 +18,8 @@ export type EffectPanelProps = { effect: Effect; isDraggable: boolean; listeners: any; - removeEffect: typeof removeEffect; - updateEffect: typeof updateEffect; + removeEffect: ActionHandler; + updateEffect: ActionHandler; style?: React.CSSProperties; onMouseDown: React.MouseEventHandler; @@ -119,7 +119,6 @@ function EffectPanelFactory( } } - // @ts-expect-error fix The types of 'propTypes.isDraggable[nominalTypeHack]' are incompatible between these types. return EffectPanel; } diff --git a/src/components/src/modals/add-map-style-modal.tsx b/src/components/src/modals/add-map-style-modal.tsx index c7886ca0c0..515778e280 100644 --- a/src/components/src/modals/add-map-style-modal.tsx +++ b/src/components/src/modals/add-map-style-modal.tsx @@ -1,8 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright contributors to the kepler.gl project -import React, {Component} from 'react'; -import {polyfill} from 'react-lifecycles-compat'; +import React, {useRef, useState, useEffect} from 'react'; import classnames from 'classnames'; import styled from 'styled-components'; import {Map, MapboxMap, MapRef} from 'react-map-gl'; @@ -111,215 +110,200 @@ interface AddMapStyleModalProps { } function AddMapStyleModalFactory() { - class AddMapStyleModal extends Component { - state = { - reRenderKey: 0, - previousToken: null - }; - - static getDerivedStateFromProps(props, state) { - if ( - props.inputStyle && - props.inputStyle.accessToken && - props.inputStyle.accessToken !== state.previousToken - ) { - // toke has changed - // ReactMapGl doesn't re-create map when token has changed - // here we force the map to update - - return { - reRenderKey: state.reRenderKey + 1, - previousToken: props.inputStyle.accessToken - }; + function AddMapStyleModal({ + inputMapStyle, + inputStyle, + loadCustomMapStyle, + mapboxApiAccessToken, + mapboxApiUrl, + transformRequest: customTransformRequest, + mapState, + intl + }: AddMapStyleModalProps) { + const [reRenderKey, setReRenderKey] = useState(0); + const [previousToken, setPreviousToken] = useState(null); + const mapRef = useRef(null); + + useEffect(() => { + if (inputStyle?.accessToken && inputStyle.accessToken !== previousToken) { + setReRenderKey(prev => prev + 1); + setPreviousToken(inputStyle.accessToken); } + }, [inputStyle?.accessToken, previousToken]); - return null; - } + const loadMapStyleJson = (style: any) => { + loadCustomMapStyle({style, error: false}); + }; - _map: MapboxMap | undefined | null; + const loadMapStyleError = () => { + loadCustomMapStyle({error: true}); + }; - _setMapRef = (mapRef: MapRef) => { + const setMapRef = (mapRefInstance: MapRef) => { // Handle change of the basemap library - if (this._map && mapRef) { - const map = mapRef.getMap(); - if (map && this._map !== map) { - this._map.off('style.load', nop); - this._map.off('error', nop); - this._map = null; + if (mapRef.current && mapRefInstance) { + const map = mapRefInstance.getMap(); + if (map && mapRef.current !== map) { + mapRef.current.off('style.load', nop); + mapRef.current.off('error', nop); + mapRef.current = null; } } - const map = mapRef && mapRef.getMap(); - if (map && this._map !== map) { - this._map = map; + const map = mapRefInstance && mapRefInstance.getMap(); + if (map && mapRef.current !== map) { + mapRef.current = map; map.on('style.load', () => { const style = map.getStyle(); - this.loadMapStyleJson(style); + loadMapStyleJson(style); }); map.on('error', () => { - this.loadMapStyleError(); + loadMapStyleError(); }); } }; - loadMapStyleJson = style => { - this.props.loadCustomMapStyle({style, error: false}); - }; - - loadMapStyleError = () => { - this.props.loadCustomMapStyle({error: true}); + const baseMapLibraryName = getBaseMapLibrary(inputStyle); + const baseMapLibraryConfig = getApplicationConfig().baseMapLibraryConfig[baseMapLibraryName]; + + const mapboxApiAccessTokenToUse = inputStyle.accessToken || mapboxApiAccessToken; + const mapProps = { + ...mapState, + mapboxAccessToken: mapboxApiAccessTokenToUse, + mapboxApiUrl, + mapLib: baseMapLibraryConfig.getMapLib(), + preserveDrawingBuffer: true, + transformRequest: + customTransformRequest?.(mapboxApiAccessTokenToUse) || + transformRequest(mapboxApiAccessTokenToUse) }; - render() { - const {inputStyle, mapState, intl} = this.props; - - const baseMapLibraryName = getBaseMapLibrary(inputStyle); - const baseMapLibraryConfig = getApplicationConfig().baseMapLibraryConfig[baseMapLibraryName]; - - const mapboxApiAccessToken = inputStyle.accessToken || this.props.mapboxApiAccessToken; - const mapProps = { - ...mapState, - // TODO baseApiUrl should be taken into account in transformRequest as we use dynamic mapLib import - // baseApiUrl: mapboxApiUrl, - mapboxAccessToken: mapboxApiAccessToken, - mapLib: baseMapLibraryConfig.getMapLib(), - preserveDrawingBuffer: true, - transformRequest: - this.props.transformRequest?.(mapboxApiAccessToken) || - transformRequest(mapboxApiAccessToken) - }; - - return ( -
- - - -
- -
-
- {intl.formatMessage({id: 'modal.addStyle.pasteSubtitle0'})} - - {' '} - {intl.formatMessage({id: 'modal.addStyle.pasteSubtitle2'})} - {' '} - {intl.formatMessage({id: 'modal.addStyle.pasteSubtitle3'})} - - {' '} - {intl.formatMessage({id: 'modal.addStyle.pasteSubtitle4'})} - -
- - this.props.inputMapStyle({ - url: value, - id: 'Custom Style', - icon: `${getApplicationConfig().cdnUrl}/${NO_BASEMAP_ICON}` - }) - } - placeholder="e.g. https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" - /> -
- - -
- -
-
- {intl.formatMessage({id: 'modal.addStyle.publishSubtitle1'})} - - {' '} - mapbox - {' '} - {intl.formatMessage({id: 'modal.addStyle.publishSubtitle2'})} - - {' '} - {intl.formatMessage({id: 'modal.addStyle.publishSubtitle3'})} - {' '} - {intl.formatMessage({id: 'modal.addStyle.publishSubtitle4'})} -
- -
- {intl.formatMessage({id: 'modal.addStyle.publishSubtitle5'})} - - {' '} - {intl.formatMessage({id: 'modal.addStyle.publishSubtitle6'})} - {' '} - {intl.formatMessage({id: 'modal.addStyle.publishSubtitle7'})} -
- this.props.inputMapStyle({accessToken: value})} - placeholder={intl.formatMessage({id: 'modal.addStyle.exampleToken'})} - /> -
+ return ( +
+ + + +
+ +
+
+ {intl.formatMessage({id: 'modal.addStyle.pasteSubtitle0'})} + + {' '} + {intl.formatMessage({id: 'modal.addStyle.pasteSubtitle2'})} + {' '} + {intl.formatMessage({id: 'modal.addStyle.pasteSubtitle3'})} + + {' '} + {intl.formatMessage({id: 'modal.addStyle.pasteSubtitle4'})} + +
+ + inputMapStyle({ + url: value, + id: 'Custom Style', + icon: `${getApplicationConfig().cdnUrl}/${NO_BASEMAP_ICON}` + }) + } + placeholder="e.g. https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" + /> +
+ + +
+ +
+
+ {intl.formatMessage({id: 'modal.addStyle.publishSubtitle1'})} + + {' '} + mapbox + {' '} + {intl.formatMessage({id: 'modal.addStyle.publishSubtitle2'})} + + {' '} + {intl.formatMessage({id: 'modal.addStyle.publishSubtitle3'})} + {' '} + {intl.formatMessage({id: 'modal.addStyle.publishSubtitle4'})} +
- -
- -
- this.props.inputMapStyle({label: value})} - placeholder="Name your style" - /> -
-
- -
- {inputStyle.error - ? ErrorMsg.styleError - : (inputStyle.style && inputStyle.style.name) || ''} +
+ {intl.formatMessage({id: 'modal.addStyle.publishSubtitle5'})} + + {' '} + {intl.formatMessage({id: 'modal.addStyle.publishSubtitle6'})} + {' '} + {intl.formatMessage({id: 'modal.addStyle.publishSubtitle7'})}
- - {/** Note, we need the Map to render with errored params to get style.error messages */} - {!inputStyle.isValid ? ( -
- ) : ( - - - - )} - - - -
- ); - } + inputMapStyle({accessToken: value})} + placeholder={intl.formatMessage({id: 'modal.addStyle.exampleToken'})} + /> + + + +
+ +
+ inputMapStyle({label: value})} + placeholder="Name your style" + /> +
+ + +
+ {inputStyle.error + ? ErrorMsg.styleError + : (inputStyle.style && inputStyle.style.name) || ''} +
+ + {!inputStyle.isValid ? ( +
+ ) : ( + + + + )} + + + +
+ ); } - return injectIntl(polyfill(AddMapStyleModal)); + return injectIntl(AddMapStyleModal); } export default AddMapStyleModalFactory; diff --git a/src/components/src/modals/data-table-modal.tsx b/src/components/src/modals/data-table-modal.tsx index 51b81643ce..de0649e1f4 100644 --- a/src/components/src/modals/data-table-modal.tsx +++ b/src/components/src/modals/data-table-modal.tsx @@ -1,11 +1,10 @@ // SPDX-License-Identifier: MIT // Copyright contributors to the kepler.gl project -import React from 'react'; +import React, {useState, useRef, useMemo, useCallback} from 'react'; import styled, {withTheme, IStyledComponent} from 'styled-components'; import DatasetLabel from '../common/dataset-label'; import DataTableFactory from '../common/data-table'; -import {createSelector} from 'reselect'; import {renderedSize} from '../common/data-table/cell-size'; import CanvasHack from '../common/data-table/canvas'; import KeplerTable, {Datasets} from '@kepler.gl/table'; @@ -144,50 +143,62 @@ function DataTableModalFactory( DataTable: ReturnType, DataTableConfig: ReturnType ): React.ComponentType> { - class DataTableModal extends React.Component { - state = { - showConfig: false - }; - - datasetCellSizeCache = {}; - dataId = ({dataId = ''}: DataTableModalProps) => dataId; - datasets = (props: DataTableModalProps) => props.datasets; - fields = ({datasets, dataId = ''}: DataTableModalProps) => (datasets[dataId] || {}).fields; - columns = createSelector(this.fields, fields => fields.map(f => f.name)); - colMeta = createSelector([this.fields, this.datasets], fields => - fields.reduce( - (acc, {name, displayName, type, filterProps, format, displayFormat}) => ({ - ...acc, - [name]: { - name: displayName || name, - type, - ...(format ? {format} : {}), - ...(displayFormat ? {displayFormat} : {}), - ...(filterProps?.columnStats ? {columnStats: filterProps.columnStats} : {}) - } - }), - {} - ) + const DataTableModal: React.FC = ({ + theme, + dataId = '', + sortTableColumn, + pinTableColumn, + copyTableColumn: copyTableColumnProp, + datasets, + showDatasetTable, + showTab = true, + setColumnDisplayFormat: setColumnDisplayFormatProp, + uiStateActions, + uiState + }) => { + const [showConfig, setShowConfig] = useState(false); + const datasetCellSizeCache = useRef>({}); + + const fields = useMemo(() => (datasets[dataId] || {}).fields, [datasets, dataId]); + + const columns = useMemo(() => fields?.map(f => f.name) || [], [fields]); + + const colMeta = useMemo( + () => + fields?.reduce( + (acc, {name, displayName, type, filterProps, format, displayFormat}) => ({ + ...acc, + [name]: { + name: displayName || name, + type, + ...(format ? {format} : {}), + ...(displayFormat ? {displayFormat} : {}), + ...(filterProps?.columnStats ? {columnStats: filterProps.columnStats} : {}) + } + }), + {} + ) || {}, + [fields] ); - cellSizeCache = createSelector(this.dataId, this.datasets, (dataId, datasets) => { + const cellSizeCache = useMemo(() => { if (!datasets[dataId]) { return {}; } const {fields, dataContainer} = datasets[dataId]; let showCalculate: boolean | null = null; - if (!this.datasetCellSizeCache[dataId]) { + if (!datasetCellSizeCache.current[dataId]) { showCalculate = true; } else if ( - this.datasetCellSizeCache[dataId].fields !== fields || - this.datasetCellSizeCache[dataId].dataContainer !== dataContainer + datasetCellSizeCache.current[dataId].fields !== fields || + datasetCellSizeCache.current[dataId].dataContainer !== dataContainer ) { showCalculate = true; } if (!showCalculate) { - return this.datasetCellSizeCache[dataId].cellSizeCache; + return datasetCellSizeCache.current[dataId].cellSizeCache; } const cellSizeCache = fields.reduce( @@ -200,113 +211,111 @@ function DataTableModalFactory( }, colIdx, type: field.type, - fontSize: this.props.theme.cellFontSize, - font: this.props.theme.fontFamily, + fontSize: theme.cellFontSize, + font: theme.fontFamily, minCellSize: MIN_STATS_CELL_SIZE }) }), {} ); + // save it to cache - this.datasetCellSizeCache[dataId] = { + datasetCellSizeCache.current[dataId] = { cellSizeCache, fields, dataContainer }; return cellSizeCache; - }); - - copyTableColumn = (column: string) => { - const {dataId = '', copyTableColumn} = this.props; - copyTableColumn(dataId, column); - }; - - pinTableColumn = (column: string) => { - const {dataId = '', pinTableColumn} = this.props; - pinTableColumn(dataId, column); - }; - - sortTableColumn = (column: string, mode?: string) => { - const {dataId = '', sortTableColumn} = this.props; - sortTableColumn(dataId, column, mode); - }; - - setColumnDisplayFormat = formats => { - const {dataId, setColumnDisplayFormat} = this.props; - if (dataId) setColumnDisplayFormat(dataId, formats); - }; - - onOpenConfig = () => { - this.setState({showConfig: true}); - }; - - onCloseConfig = () => { - this.setState({showConfig: false}); - }; - - render() { - const {datasets, dataId, showDatasetTable, showTab = true} = this.props; - if (!datasets || !dataId) { - return null; - } - const activeDataset = datasets[dataId]; - const columns = this.columns(this.props); - const colMeta = this.colMeta(this.props); - const cellSizeCache = this.cellSizeCache(this.props); - - return ( - - - - {showTab ? ( - - ) : null} - - - - - - - {datasets[dataId] ? ( - { + copyTableColumnProp(dataId, column); + }, + [copyTableColumnProp, dataId] + ); + + const handlePinTableColumn = useCallback( + (column: string) => { + pinTableColumn(dataId, column); + }, + [pinTableColumn, dataId] + ); + + const handleSortTableColumn = useCallback( + (column: string, mode?: string) => { + sortTableColumn(dataId, column, mode); + }, + [sortTableColumn, dataId] + ); + + const handleSetColumnDisplayFormat = useCallback( + formats => { + if (dataId) setColumnDisplayFormatProp(dataId, formats); + }, + [setColumnDisplayFormatProp, dataId] + ); + + const onOpenConfig = useCallback(() => { + setShowConfig(true); + }, []); + + const onCloseConfig = useCallback(() => { + setShowConfig(false); + }, []); + + if (!datasets || !dataId) { + return null; + } + + const activeDataset = datasets[dataId]; + + return ( + + + + {showTab ? ( + + ) : null} + + + + - ) : null} - - - ); - } - } + + + {datasets[dataId] ? ( + + ) : null} + + + ); + }; - // @ts-expect-error figure out the proper way to type - return withTheme(DataTableModal); + return withTheme(DataTableModal) as React.ComponentType>; } export default DataTableModalFactory; diff --git a/src/components/src/modals/export-data-modal.tsx b/src/components/src/modals/export-data-modal.tsx index c871977b2f..b55ebdb93a 100644 --- a/src/components/src/modals/export-data-modal.tsx +++ b/src/components/src/modals/export-data-modal.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright contributors to the kepler.gl project -import React, {Component} from 'react'; +import React, {useEffect} from 'react'; import {injectIntl, IntlShape} from 'react-intl'; import {DatasetType, EXPORT_DATA_TYPE_OPTIONS} from '@kepler.gl/constants'; @@ -67,134 +67,132 @@ export interface ExportDataModalProps { } const ExportDataModalFactory = () => { - class ExportDataModal extends Component { - componentDidMount() { - const toCPUFilter = this.props.selectedDataset || Object.keys(this.props.datasets); - this.props.applyCPUFilter(toCPUFilter); - } + const ExportDataModal = ({ + supportedDataTypes = EXPORT_DATA_TYPE_OPTIONS, + datasets, + selectedDataset, + dataType, + filtered, + onChangeExportDataType, + onChangeExportFiltered, + applyCPUFilter, + onChangeExportSelectedDataset, + intl + }: ExportDataModalProps) => { + useEffect(() => { + const toCPUFilter = selectedDataset || Object.keys(datasets); + applyCPUFilter(toCPUFilter); + }, [selectedDataset, datasets, applyCPUFilter]); - _onSelectDataset: React.ChangeEventHandler = ({target: {value}}) => { - this.props.applyCPUFilter(value); - this.props.onChangeExportSelectedDataset(value); + const onSelectDataset: React.ChangeEventHandler = ({target: {value}}) => { + applyCPUFilter(value); + onChangeExportSelectedDataset(value); }; - render() { - const { - supportedDataTypes = EXPORT_DATA_TYPE_OPTIONS, - datasets, - selectedDataset, - dataType, - filtered, - onChangeExportDataType, - onChangeExportFiltered, - intl - } = this.props; - - const exportAllDatasets = selectedDataset ? !datasets[selectedDataset] : true; - const showTiledDatasetWarning = Object.keys(datasets).some(datasetId => { - return ( - (datasets[datasetId].type === DatasetType.VECTOR_TILE || - datasets[datasetId].type === DatasetType.RASTER_TILE) && - (selectedDataset === datasetId || exportAllDatasets) - ); - }); - + const exportAllDatasets = selectedDataset ? !datasets[selectedDataset] : true; + const showTiledDatasetWarning = Object.keys(datasets).some(datasetId => { return ( - -
- -
-
- -
-
- -
+ (datasets[datasetId].type === DatasetType.VECTOR_TILE || + datasets[datasetId].type === DatasetType.RASTER_TILE) && + (selectedDataset === datasetId || exportAllDatasets) + ); + }); + + return ( + +
+ +
+
+
-
- +
+
- - -
-
- -
-
- -
+
+
+ +
+
+ +
+
+
-
- {supportedDataTypes.map(op => ( - op.available && onChangeExportDataType(op.id)} - > - - {dataType === op.id && } - - ))} +
+
- - -
-
- -
-
- -
-
-
- onChangeExportFiltered(false)} - > -
- -
-
- {getDataRowCount(datasets, selectedDataset, false, intl)} -
- {!filtered && } -
- onChangeExportFiltered(true)} +
+
+ {supportedDataTypes.map(op => ( + op.available && onChangeExportDataType(op.id)} > -
- -
-
- {getDataRowCount(datasets, selectedDataset, true, intl)} -
- {filtered && } - -
-
- {showTiledDatasetWarning ? ( + + {dataType === op.id && } + + ))} +
+ + +
- - - +
- ) : null} -
- - ); - } - } +
+ +
+
+
+ onChangeExportFiltered(false)} + > +
+ +
+
+ {getDataRowCount(datasets, selectedDataset, false, intl)} +
+ {!filtered && } +
+ onChangeExportFiltered(true)} + > +
+ +
+
+ {getDataRowCount(datasets, selectedDataset, true, intl)} +
+ {filtered && } +
+
+
+ {showTiledDatasetWarning ? ( +
+ + + +
+ ) : null} +
+ + ); + }; return injectIntl(ExportDataModal); }; diff --git a/src/components/src/notification-panel.tsx b/src/components/src/notification-panel.tsx index 79b47c885b..32004e863a 100644 --- a/src/components/src/notification-panel.tsx +++ b/src/components/src/notification-panel.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright contributors to the kepler.gl project -import React, {Component} from 'react'; +import React from 'react'; import styled from 'styled-components'; import NotificationItemFactory from './notification-panel/notification-item'; @@ -33,33 +33,26 @@ interface NotificationPanelProps { export default function NotificationPanelFactory( NotificationItem: ReturnType -): React.ComponentClass { - class NotificationPanelUnmemoized extends Component { - static displayName = 'NotificationPanel'; +): React.FC { + const NotificationPanel: React.FC = ({ + notifications, + removeNotification + }) => { + const globalNotifications = notifications.filter( + n => n.topic === DEFAULT_NOTIFICATION_TOPICS.global + ); + + return ( + + {globalNotifications.map(n => ( + + ))} + + ); + }; - render() { - const globalNotifications = this.props.notifications.filter( - n => n.topic === DEFAULT_NOTIFICATION_TOPICS.global - ); - return ( - - {globalNotifications.map(n => ( - - ))} - - ); - } - } - - const NotificationPanel = React.memo( - NotificationPanelUnmemoized - ) as unknown as typeof NotificationPanelUnmemoized; return NotificationPanel; } diff --git a/src/components/src/notification-panel/notification-item.tsx b/src/components/src/notification-panel/notification-item.tsx index 40e267b007..6fcb287b4e 100644 --- a/src/components/src/notification-panel/notification-item.tsx +++ b/src/components/src/notification-panel/notification-item.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright contributors to the kepler.gl project -import React, {Component} from 'react'; +import React, {useState, useEffect} from 'react'; import styled from 'styled-components'; import {Delete, Info, Warning, Checkmark} from '../common/icons'; import Markdown from 'markdown-to-jsx'; @@ -118,58 +118,56 @@ interface NotificationItemProps { } export default function NotificationItemFactory() { - return class NotificationItem extends Component { - state = { - isExpanded: false - }; - - componentDidMount() { - if (this.props.isExpanded) { - this.setState({isExpanded: true}); + return function NotificationItem({ + notification, + removeNotification, + isExpanded: initialIsExpanded, + theme + }: NotificationItemProps) { + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + if (initialIsExpanded) { + setIsExpanded(true); } - } - - render() { - const {notification, removeNotification} = this.props; - const {isExpanded} = this.state; - - return ( - - {(notification.count || 0) > 1 ? ( - - {notification.count} - - ) : null} - this.setState({isExpanded: !isExpanded})} - > - - {icons[notification.type]} - - - + {(notification.count || 0) > 1 ? ( + + {notification.count} + + ) : null} + setIsExpanded(!isExpanded)} + > + + {icons[notification.type]} + + + - {notification.message} - - - {typeof removeNotification === 'function' ? ( -
- removeNotification(notification.id)} /> -
- ) : null} -
-
- ); - } + } + }} + > + {notification.message} + + + {typeof removeNotification === 'function' ? ( +
+ removeNotification(notification.id)} /> +
+ ) : null} + + + ); }; }