diff --git a/components/DropDownMenu/DropDownModal.js b/components/DropDownMenu/DropDownModal.js index fc3fab48..f6d96700 100644 --- a/components/DropDownMenu/DropDownModal.js +++ b/components/DropDownMenu/DropDownModal.js @@ -2,22 +2,22 @@ import PropTypes from 'prop-types'; import React, { PureComponent } from 'react'; import { Modal, - ListView, LayoutAnimation, Dimensions, NativeModules, } from 'react-native'; import _ from 'lodash'; -import { connectStyle, changeColorAlpha } from '@shoutem/theme'; import { TimingDriver, FadeIn, ZoomOut } from '@shoutem/animation'; +import { connectStyle, changeColorAlpha } from '@shoutem/theme'; import { Button } from '../Button'; import { Icon } from '../Icon'; -import { Text } from '../Text'; -import { View } from '../View'; import { LinearGradient } from '../LinearGradient'; +import { ListView } from '../ListView'; +import { Text } from '../Text'; import { TouchableOpacity } from '../TouchableOpacity'; +import { View } from '../View'; const window = Dimensions.get('window'); @@ -76,17 +76,18 @@ class DropDownModal extends PureComponent { constructor(props) { super(props); - this.state = { - optionHeight: 0, - shouldRenderModalContent: false, - }; + this.close = this.close.bind(this); this.emitOnOptionSelectedEvent = this.emitOnOptionSelectedEvent.bind(this); this.renderGradient = this.renderGradient.bind(this); this.renderRow = this.renderRow.bind(this); this.selectOption = this.selectOption.bind(this); this.onOptionLayout = this.onOptionLayout.bind(this); - this.ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2 }); + + this.state = { + optionHeight: 0, + shouldRenderModalContent: false, + }; } componentWillMount() { @@ -242,12 +243,13 @@ class DropDownModal extends PureComponent { render() { const { titleProperty, options, style } = this.props; const { shouldRenderModalContent } = this.state; + if (_.size(options) === 0) { return null; } const listViewStyle = this.resolveListViewStyle(); - const dataSource = this.ds.cloneWithRows(options.filter((option) => option[titleProperty])); + const data = options.filter((option) => option[titleProperty]); return ( {shouldRenderModalContent ? + + + ); + } + + render() { + const { icon, message, onRetry } = this.props; + + return ( + + + + + + {message} + + {onRetry && this.renderRetryButton()} + + ); + } +} + +EmptyStateView.propTypes = { + ...EmptyStateView.propTypes, + onRetry: PropTypes.func, + message: PropTypes.string, + icon: PropTypes.string, +}; + +const StyledView = connectStyle('shoutem.ui.EmptyStateView')(EmptyStateView); + +export { + StyledView as EmptyStateView, +}; diff --git a/components/LinearGradient.js b/components/LinearGradient.js index 551a7ac6..8553ce69 100644 --- a/components/LinearGradient.js +++ b/components/LinearGradient.js @@ -1,22 +1,26 @@ import React, { PureComponent } from 'react'; import RNLinearGradient from 'react-native-linear-gradient'; import _ from 'lodash'; + import { connectAnimation } from '@shoutem/animation'; import { connectStyle } from '@shoutem/theme'; -const RNLinearGradientPropsKeys = Object.keys(RNLinearGradient.propTypes); +const RNLinearGradientPropsKeys = ['start', 'end', 'colors', 'locations']; class LinearGradient extends PureComponent { render () { const { props } = this; - const style = { ..._.omit(props.style, RNLinearGradientPropsKeys) }; + const styleWithOmissions = _.omit(props.style, RNLinearGradientPropsKeys); + const linearGradientProps = { + ...props, + ..._.pick(props.style, RNLinearGradientPropsKeys), + }; return ( {props.children} diff --git a/components/ListView.js b/components/ListView.js index 98166538..7c594a3b 100644 --- a/components/ListView.js +++ b/components/ListView.js @@ -2,7 +2,8 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { View, - ListView as RNListView, + FlatList, + SectionList, RefreshControl, StatusBar, Platform, @@ -12,6 +13,8 @@ import _ from 'lodash'; import { connectStyle } from '@shoutem/theme'; +import { Caption } from './Text'; +import { Divider } from './Divider'; import { Spinner } from './Spinner'; const scrollViewProps = _.keys(ScrollView.propTypes); @@ -23,49 +26,6 @@ const Status = { IDLE: 'idle', }; -/** - * Provides dataSource to ListView. - * Clones items and group them by section if needed. - */ -class ListDataSource { - constructor(config, getSectionId) { - this.getSectionId = getSectionId; - this.withSections = !!config.sectionHeaderHasChanged; - this.dataSource = new RNListView.DataSource(config); - } - - /** - * Transforms items list ([...items]) to [[...sectionItems], [...sectionItems]] - * @param data - * @returns {*} - */ - groupItemsIntoSections(data) { - let prevSectionId; - return data.reduce((sections, item) => { - const sectionId = this.getSectionId(item); - if (prevSectionId !== sectionId) { - prevSectionId = sectionId; - sections.push([]); - } - const lastSectionIndex = sections.length - 1; - sections[lastSectionIndex].push(item); - return sections; - }, []); - } - - /** - * Transforms items list [, ] - * @param data - * @returns {*} - */ - clone(data) { - if (this.withSections) { - return this.dataSource.cloneWithRowsAndSections(this.groupItemsIntoSections(data)); - } - return this.dataSource.cloneWithRows(data); - } -} - class ListView extends Component { static propTypes = { autoHideHeader: PropTypes.bool, @@ -75,12 +35,14 @@ class ListView extends Component { onLoadMore: PropTypes.func, onRefresh: PropTypes.func, getSectionId: PropTypes.func, + sections: PropTypes.object, renderRow: PropTypes.func, renderHeader: PropTypes.func, renderFooter: PropTypes.func, renderSectionHeader: PropTypes.func, scrollDriver: PropTypes.object, - // TODO(Braco) - add render separator + hasFeaturedItem: PropTypes.bool, + renderFeaturedItem: PropTypes.func, }; constructor(props, context) { @@ -92,25 +54,12 @@ class ListView extends Component { this.renderRefreshControl = this.renderRefreshControl.bind(this); this.listView = null; - - this.listDataSource = new ListDataSource({ - rowHasChanged: (r1, r2) => r1 !== r2, - sectionHeaderHasChanged: props.renderSectionHeader ? (s1, s2) => s1 !== s2 : undefined, - getSectionHeaderData: (dataBlob, sectionId) => props.getSectionId(dataBlob[sectionId][0]), - }, props.getSectionId); - - this.state = { status: props.loading ? Status.LOADING : Status.IDLE, - dataSource: this.listDataSource.clone(props.data), }; } componentWillReceiveProps(nextProps) { - if (nextProps.data !== this.props.data) { - this.setState({ dataSource: this.listDataSource.clone(nextProps.data) }); - } - if (nextProps.loading !== this.props.loading) { this.setLoading(nextProps.loading); } @@ -153,26 +102,46 @@ class ListView extends Component { // configuration // default load more threshold mappedProps.onEndReachedThreshold = 40; - // React native warning - // NOTE: In react 0.23 it can't be set to false - mappedProps.enableEmptySections = true; // style mappedProps.style = props.style.list; - mappedProps.contentContainerStyle = props.style.listContent; // rendering mappedProps.renderHeader = this.createRenderHeader(props.renderHeader, props.autoHideHeader); - mappedProps.renderRow = props.renderRow; - mappedProps.renderFooter = this.renderFooter; - mappedProps.renderSectionHeader = props.renderSectionHeader; + mappedProps.renderItem = (data) => props.renderRow(data.item); + mappedProps.ListFooterComponent = this.renderFooter; + + if (props.hasFeaturedItem && !props.sections) { + mappedProps.sections = [ + { data: [props.data[0]], renderItem: (data) => props.renderFeaturedItem(data.item) }, + { data: props.data.slice(1) }, + ] + } + + if (props.renderSectionHeader) { + mappedProps.renderSectionHeader = ({section}) => props.renderSectionHeader(section); + } + else if (!props.hasFeaturedItem) { + mappedProps.renderSectionHeader = ({section}) => this.renderDefaultSectionHeader(section); + } // events mappedProps.onEndReached = this.createOnLoadMore(); // data to display - mappedProps.dataSource = this.state.dataSource; + mappedProps.data = props.data; + + // key extractor + mappedProps.keyExtractor = (item, index) => index.toString(); + + // sections for SectionList + if (props.sections) { + mappedProps.sections = props.sections; + } + + // is data refreshing + mappedProps.refreshing = this.state.refreshing === Status.REFRESHING; // refresh control mappedProps.refreshControl = props.onRefresh && this.renderRefreshControl(); @@ -253,6 +222,16 @@ class ListView extends Component { this.listView = listView; } + renderDefaultSectionHeader(section) { + const title = _.get(section, 'title', ''); + + return ( + + {title.toUpperCase()} + + ); + } + renderFooter() { const { style, renderFooter } = this.props; const { status } = this.state; @@ -305,7 +284,13 @@ class ListView extends Component { } render() { - return ; + const { sections, hasFeaturedItem } = this.props; + + if (sections || hasFeaturedItem) { + return ; + } + + return ; } } diff --git a/components/NumberInput.js b/components/NumberInput.js new file mode 100644 index 00000000..5446dfcd --- /dev/null +++ b/components/NumberInput.js @@ -0,0 +1,141 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; + +import { connectStyle } from '@shoutem/theme'; +import { connectAnimation } from '@shoutem/animation'; + +import { Icon } from './Icon'; +import { View } from './View'; +import { Button } from './Button'; +import { TextInput } from './TextInput'; + +const { func, number, object, oneOfType, shape, string } = PropTypes; + +/** + * A component for entering a numerical value with two helper buttons for increasing + * and decreasing the value. + * + * The current implementation allows only positive integers. It removes any character + * that is not a digit, if the user enters it directly or by pasting. + * + * The component allows empty values. The consumer needs to handle it as he sees fit. + * + * TODO: Future implementations should define how to handle validation in combination with + * focus and blur events. + */ +class NumberInput extends PureComponent { + static propTypes = { + ...TextInput.propTypes, + // Maximum allowed value + max: number, + // Minimum allowed value + min: number, + // Called when the user changes the value by inputting it directly or with buttons + onChange: func.isRequired, + // Step used to increase or decrease value with corresponding buttons + step: number, + // Styles for component parts + style: shape({ + button: object, + container: object, + icon: object, + input: object, + inputContainer: object, + }), + // Value of the input - can be empty + value: oneOfType([ + number, + string, + ]), + }; + + constructor(props) { + super(props); + + this.decreaseValue = this.decreaseValue.bind(this); + this.increaseValue = this.increaseValue.bind(this); + this.onChangeText = this.onChangeText.bind(this); + } + + onChangeText(text) { + const transformedText = text.replace(/[^0-9]/g, ''); + + this.updateValue(parseInt(transformedText, 10)); + } + + decreaseValue() { + const { step = 1, value = 0 } = this.props; + + this.updateValue(value - step); + } + + increaseValue() { + const { step = 1, value = 0 } = this.props; + + this.updateValue(value + step); + } + + updateValue(value) { + const { max, min, onChange } = this.props; + + if (!_.isFinite(value)) { + return onChange(''); + } + + let newValue = _.isFinite(max) && value > max ? max : value; + newValue = _.isFinite(min) && newValue < min ? min : newValue; + + return onChange(newValue); + } + + render() { + const { style, value } = this.props; + + return ( + + + + + + + + ); + } +} + +const AnimatedNumberInput = connectAnimation(NumberInput); +const StyledNumberInput = connectStyle('shoutem.ui.NumberInput')(AnimatedNumberInput); + +export { + StyledNumberInput as NumberInput, +}; diff --git a/components/RichMedia.js b/components/RichMedia.js deleted file mode 100644 index 81c979be..00000000 --- a/components/RichMedia.js +++ /dev/null @@ -1,13 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import { Html } from '../html'; - -export default function RichMedia({ body }) { - console.warn("'RichMedia' is deprecated and will be removed soon. Use 'Html' instead."); - return ; -} - -RichMedia.propTypes = { - body: PropTypes.string.isRequired, -}; diff --git a/components/SearchField.js b/components/SearchField.js new file mode 100644 index 00000000..c8297807 --- /dev/null +++ b/components/SearchField.js @@ -0,0 +1,90 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; + +import { connectStyle } from '@shoutem/theme'; +import { connectAnimation } from '@shoutem/animation'; + +import { Icon } from './Icon'; +import { View } from './View'; +import { Button } from './Button'; +import { TextInput } from './TextInput'; + +const { func, object, shape, string } = PropTypes; + +const ClearButton = ({ style, onPress }) => ( + +); + +ClearButton.propTypes = { + style: object, + onPress: func, +}; + +/** + * A component that allows the user to enter a search query. + * It has a search icon, placeholder and a button that clears the current query. + * + */ +class SearchField extends PureComponent { + static propTypes = { + // A placeholder for input when no value is entered + placeholder: string, + // Called with the new value on text change + onChangeText: func, + // Styles for container and search icon + style: shape({ + clearIcon: object, + container: object, + input: object, + searchIcon: object, + }), + // Value to render as text in search input + value: string, + }; + + render() { + const { onChangeText, placeholder, style, value, ...rest } = this.props; + + return ( + + + + {value && ( + onChangeText('')} + style={style} + /> + )} + + ); + } +} + +const AnimatedSearchField = connectAnimation(SearchField); +const StyledSearchField = connectStyle('shoutem.ui.SearchField')(AnimatedSearchField); + +export { + StyledSearchField as SearchField, +}; diff --git a/components/Video/Video.js b/components/Video/Video.js index d99e1b37..7b005341 100644 --- a/components/Video/Video.js +++ b/components/Video/Video.js @@ -1,13 +1,10 @@ import PropTypes from 'prop-types'; import React, { PureComponent } from 'react'; +import { View } from 'react-native'; +import { WebView } from 'react-native-webview'; -import { - View, - WebView, -} from 'react-native'; - -import { connectStyle } from '@shoutem/theme'; import { connectAnimation } from '@shoutem/animation'; +import { connectStyle } from '@shoutem/theme'; import VideoSourceReader from './VideoSourceReader'; diff --git a/examples/components/Buttons.js b/examples/components/Buttons.js index 734ca12a..8e228946 100644 --- a/examples/components/Buttons.js +++ b/examples/components/Buttons.js @@ -66,7 +66,7 @@ export function Buttons() { - + - )} - centerComponent={TITLE} - /> - - - - - - - - )} - centerComponent={TITLE} - rightComponent={} - /> - - - - - - - - )} - centerComponent={TITLE} - rightComponent={( - - )} - /> - - - - - - - - )} - centerComponent={TITLE} - rightComponent={( - - )} - /> - - - - - - - - )} - title="TITLE" - share={{ - link: 'http://shoutem.github.io', - text: 'This is the best', - title: 'Super cool UI Toolkit', - }} - /> - - - - - - - - )} - title="TITLE" - share={{ - link: 'http://shoutem.github.io', - text: 'This is the best', - title: 'Super cool UI Toolkit', + return ( + + + + + + + + + + + + + + + - - - - - - - - )} - title="TITLE" - rightComponent={( - - )} - /> - - - - - - - - )} - title="TITLE" - share={{ - link: 'http://shoutem.github.io', - text: 'This is the best', - title: 'Super cool UI Toolkit', - }} - /> - - - - - - - - )} - title="TITLE" - rightComponent={( - - )} - /> - - - - - - Cancel - - )} - title="TITLE" - rightComponent={( - - )} - /> - - - - - - Cancel - - )} - title="TITLE" - rightComponent={( - - )} - /> - - - - - - - - )} - title="TITLE" - share={{ - link: 'http://shoutem.github.io', - text: 'This is the best', - title: 'Super cool UI Toolkit', - }} - /> - - - - ); + > + TITLE} + /> + + + Navigation bar variations + + + + + + )} + centerComponent={TITLE} + /> + + + + + + + + )} + centerComponent={TITLE} + rightComponent={} + /> + + + + + + + + )} + centerComponent={TITLE} + rightComponent={( + + )} + /> + + + + + + + + )} + centerComponent={TITLE} + rightComponent={( + + )} + /> + + + + + + + + )} + title="TITLE" + share={{ + link: 'http://shoutem.github.io', + text: 'This is the best', + title: 'Super cool UI Toolkit', + }} + /> + + + + + + + + )} + title="TITLE" + share={{ + link: 'http://shoutem.github.io', + text: 'This is the best', + title: 'Super cool UI Toolkit', + }} + /> + + + + + + + + )} + title="TITLE" + rightComponent={( + + )} + /> + + + + + + + + )} + title="TITLE" + share={{ + link: 'http://shoutem.github.io', + text: 'This is the best', + title: 'Super cool UI Toolkit', + }} + /> + + + + + + + + )} + title="TITLE" + rightComponent={( + + )} + /> + + + + + + Cancel + + )} + title="TITLE" + rightComponent={( + + )} + /> + + + + + + Cancel + + )} + title="TITLE" + rightComponent={( + + )} + /> + + + + + + + + )} + title="TITLE" + share={{ + link: 'http://shoutem.github.io', + text: 'This is the best', + title: 'Super cool UI Toolkit', + }} + /> + + + + ); + } } diff --git a/examples/components/Videos.js b/examples/components/Videos.js index 1baf2e06..3f028feb 100644 --- a/examples/components/Videos.js +++ b/examples/components/Videos.js @@ -1,5 +1,5 @@ import React from 'react'; -import { WebView } from 'react-native'; +import { WebView } from 'react-native-webview'; import { View } from '../../components/View'; import { Image } from '../../components/Image'; @@ -18,9 +18,9 @@ export function Videos() { diff --git a/html/Html.js b/html/Html.js index 3b104020..2a0260f1 100644 --- a/html/Html.js +++ b/html/Html.js @@ -3,9 +3,10 @@ import React, { PureComponent } from 'react'; import { Platform, InteractionManager } from 'react-native'; import _ from 'lodash'; -import { View, Spinner } from '@shoutem/ui'; import { connectStyle } from '@shoutem/theme'; +import { Spinner } from '../components/Spinner'; +import { View } from '../components/View'; import { parseHtml } from './services/HtmlParser'; import { registerElement, diff --git a/html/components/Gallery.js b/html/components/Gallery.js index 1bb6706b..5680bb9f 100644 --- a/html/components/Gallery.js +++ b/html/components/Gallery.js @@ -33,7 +33,7 @@ export default class Gallery extends PureComponent { const { selectedIndex } = this.state; if (!handlePhotoPress) { - console.log('There is no "handlePhotoPress" handler for RichMediaGallery photo.'); + console.log('There is no "handlePhotoPress" handler for Gallery photo.'); return; } diff --git a/html/elements/Br.js b/html/elements/Br.js index 57e79d35..75472bbf 100644 --- a/html/elements/Br.js +++ b/html/elements/Br.js @@ -1,5 +1,6 @@ import React from 'react'; -import { Text } from '@shoutem/ui'; + +import { Text } from '../../components/Text'; function Br() { return ( diff --git a/html/elements/list/Ul.js b/html/elements/list/Ul.js index 371da32e..a249ef35 100644 --- a/html/elements/list/Ul.js +++ b/html/elements/list/Ul.js @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { Text } from '@shoutem/ui'; import { View } from '../../../components/View'; +import { Text } from '../../../components/Text'; import { ElementPropTypes, combineMappers, mapElementProps } from '../../Html'; import renderItems from './helpers/renderItems'; import pickLiChildElements from './helpers/pickLiChildElements'; diff --git a/html/elements/list/prefix/Bullet.js b/html/elements/list/prefix/Bullet.js index cb86bc4c..c2822799 100644 --- a/html/elements/list/prefix/Bullet.js +++ b/html/elements/list/prefix/Bullet.js @@ -1,5 +1,6 @@ import React from 'react'; -import { Text } from '@shoutem/ui'; + +import { Text } from '../../../../components/Text'; export default function ({ style }) { return ; diff --git a/html/elements/list/prefix/Number.js b/html/elements/list/prefix/Number.js index bea9cca0..1512174a 100644 --- a/html/elements/list/prefix/Number.js +++ b/html/elements/list/prefix/Number.js @@ -1,5 +1,7 @@ import React from 'react'; -import { View, Text } from '@shoutem/ui'; + +import { View } from '../../../../components/View'; +import { Text } from '../../../../components/Text'; export default function ({ element, style }) { const { index } = element.attributes; diff --git a/index.js b/index.js index 5e7d73d3..e5c1eac7 100644 --- a/index.js +++ b/index.js @@ -41,7 +41,6 @@ export { ImageGalleryOverlay } from './components/ImageGalleryOverlay'; export { HorizontalPager } from './components/HorizontalPager'; export { LoadingIndicator } from './components/LoadingIndicator'; export { PageIndicators } from './components/PageIndicators'; -export { default as RichMedia } from './components/RichMedia'; export { Html } from './html'; export { SimpleHtml } from './html'; export { ShareButton } from './components/ShareButton'; @@ -63,6 +62,10 @@ export { Tile } from './components/Tile'; export { Lightbox } from './components/Lightbox'; +export { EmptyStateView } from './components/EmptyStateView'; +export { NumberInput } from './components/NumberInput'; +export { SearchField } from './components/SearchField'; + export { Examples } from './examples/components'; export { Device } from './helpers'; diff --git a/package.json b/package.json index 14ff4bb3..93d30776 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shoutem/ui", - "version": "0.23.20", + "version": "1.0.0", "description": "Styleable set of components for React Native applications", "dependencies": { "@shoutem/animation": "~0.12.3", @@ -12,19 +12,20 @@ "htmlparser2": "~3.9.0", "lodash": "~4.17.4", "qs": "~4.0.0", - "react-native-render-html": "~4.1.2", + "prop-types": "^15.5.10", "react-native-lightbox": "shoutem/react-native-lightbox#v0.7.2", - "react-native-linear-gradient": "~2.4.0", - "react-native-transformable-image": "shoutem/react-native-transformable-image#v0.0.20", - "react-native-vector-icons": "~4.3.0", - "react-native-photo-view": "shoutem/react-native-photo-view#2f650beba07a263876c54f81a34b5ac2f9b805ce", + "react-native-linear-gradient": "~2.5.4", "react-native-navigation-experimental-compat": "shoutem/react-native-navigation-experimental-compat#fbce4b9e478f808634a98dac48901ec6ed4ee9fd", + "react-native-photo-view": "shoutem/react-native-photo-view#cfc3f50a2a3a83a3848a570ad86a1f732001ad57", + "react-native-render-html": "~4.1.2", + "react-native-transformable-image": "shoutem/react-native-transformable-image#v0.0.20", + "react-native-vector-icons": "~6.5.0", + "react-native-webview": "5.12.1", "stream": "0.0.2", - "tinycolor2": "~1.3.0", - "prop-types": "^15.5.10" + "tinycolor2": "~1.3.0" }, "peerDependencies": { - "react": "^16.0.0", + "react": "^16.6.3", "react-native": ">=0.40.0" }, "devDependencies": { diff --git a/scripts/link.js b/scripts/link.js index 12839725..1134b457 100644 --- a/scripts/link.js +++ b/scripts/link.js @@ -9,6 +9,7 @@ const nativeDependencies = [ 'react-native-linear-gradient', 'react-native-share', 'react-native-photo-view', + 'react-native-webview', ]; const reactNativeLocalCli = `node node_modules/react-native/local-cli/cli.js`; diff --git a/theme.js b/theme.js index 6b709269..0dad558b 100644 --- a/theme.js +++ b/theme.js @@ -1433,12 +1433,21 @@ export default (variables = defaultThemeVariables) => ({ }, container: { [INCLUDE]: ['fillParent'], - height: NAVIGATION_HEADER_HEIGHT, + height: NAVIGATION_BAR_HEIGHT, backgroundColor: variables.navBarBackground, borderBottomColor: variables.navBarBorderColor, borderBottomWidth: StyleSheet.hairlineWidth, // Leave space for the status bar on iOS - paddingTop: Platform.OS === 'ios' ? 20 : 0, + paddingTop: Device.select({ + iPhoneX: 0, + iPhoneXR: 0, + default: ( + Platform.OS === 'ios' + && !Device.isIphoneX + && !Device.isIphoneXR + ? 20 : 0 + ), + }), }, componentsContainer: {