perf(explore): virtualized datasource field sections (#27625)

This commit is contained in:
JUST.in DO IT 2024-03-27 11:25:55 -07:00 committed by GitHub
parent c73b24ad08
commit 38eecfc5d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 504 additions and 185 deletions

View File

@ -200,6 +200,7 @@
"@types/react-table": "^7.7.19",
"@types/react-transition-group": "^4.4.10",
"@types/react-ultimate-pagination": "^1.2.0",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.5",
"@types/redux-localstorage": "^1.0.8",
"@types/redux-mock-store": "^1.0.2",
@ -23347,6 +23348,15 @@
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized-auto-sizer": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz",
"integrity": "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-window": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz",
@ -89843,6 +89853,15 @@
"@types/react": "*"
}
},
"@types/react-virtualized-auto-sizer": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz",
"integrity": "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-window": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz",

View File

@ -266,6 +266,7 @@
"@types/react-table": "^7.7.19",
"@types/react-transition-group": "^4.4.10",
"@types/react-ultimate-pagination": "^1.2.0",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.5",
"@types/redux-localstorage": "^1.0.8",
"@types/redux-mock-store": "^1.0.2",

View File

@ -30,6 +30,17 @@ import {
import { DatasourceType } from '@superset-ui/core';
import DatasourceControl from 'src/explore/components/controls/DatasourceControl';
jest.mock(
'react-virtualized-auto-sizer',
() =>
({
children,
}: {
children: (params: { height: number }) => React.ReactChild;
}) =>
children({ height: 500 }),
);
const datasource: IDatasource = {
id: 1,
type: DatasourceType.Table,
@ -69,6 +80,7 @@ const props: DatasourcePanelProps = {
actions: {
setControlValue: jest.fn(),
},
width: 300,
};
const search = (value: string, input: HTMLElement) => {

View File

@ -0,0 +1,168 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import {
columns,
metrics,
} from 'src/explore/components/DatasourcePanel/fixtures';
import { fireEvent, render, within } from 'spec/helpers/testing-library';
import DatasourcePanelItem from './DatasourcePanelItem';
const mockData = {
metricSlice: metrics,
columnSlice: columns,
totalMetrics: Math.max(metrics.length, 10),
totalColumns: Math.max(columns.length, 13),
width: 300,
showAllMetrics: false,
onShowAllMetricsChange: jest.fn(),
showAllColumns: false,
onShowAllColumnsChange: jest.fn(),
collapseMetrics: false,
onCollapseMetricsChange: jest.fn(),
collapseColumns: false,
onCollapseColumnsChange: jest.fn(),
};
test('renders each item accordingly', () => {
const { getByText, getByTestId, rerender, container } = render(
<DatasourcePanelItem index={0} data={mockData} style={{}} />,
{ useDnd: true },
);
expect(getByText('Metrics')).toBeInTheDocument();
rerender(<DatasourcePanelItem index={1} data={mockData} style={{}} />);
expect(
getByText(
`Showing ${mockData.metricSlice.length} of ${mockData.totalMetrics}`,
),
).toBeInTheDocument();
mockData.metricSlice.forEach((metric, metricIndex) => {
rerender(
<DatasourcePanelItem
index={metricIndex + 2}
data={mockData}
style={{}}
/>,
);
expect(getByTestId('DatasourcePanelDragOption')).toBeInTheDocument();
expect(
within(getByTestId('DatasourcePanelDragOption')).getByText(
metric.metric_name,
),
).toBeInTheDocument();
});
rerender(
<DatasourcePanelItem
index={2 + mockData.metricSlice.length}
data={mockData}
style={{}}
/>,
);
expect(container).toHaveTextContent('');
const startIndexOfColumnSection = mockData.metricSlice.length + 3;
rerender(
<DatasourcePanelItem
index={startIndexOfColumnSection}
data={mockData}
style={{}}
/>,
);
expect(getByText('Columns')).toBeInTheDocument();
rerender(
<DatasourcePanelItem
index={startIndexOfColumnSection + 1}
data={mockData}
style={{}}
/>,
);
expect(
getByText(
`Showing ${mockData.columnSlice.length} of ${mockData.totalColumns}`,
),
).toBeInTheDocument();
mockData.columnSlice.forEach((column, columnIndex) => {
rerender(
<DatasourcePanelItem
index={startIndexOfColumnSection + columnIndex + 2}
data={mockData}
style={{}}
/>,
);
expect(getByTestId('DatasourcePanelDragOption')).toBeInTheDocument();
expect(
within(getByTestId('DatasourcePanelDragOption')).getByText(
column.column_name,
),
).toBeInTheDocument();
});
});
test('can collapse metrics and columns', () => {
mockData.onCollapseMetricsChange.mockClear();
mockData.onCollapseColumnsChange.mockClear();
const { queryByText, getByRole, rerender } = render(
<DatasourcePanelItem index={0} data={mockData} style={{}} />,
{ useDnd: true },
);
fireEvent.click(getByRole('button'));
expect(mockData.onCollapseMetricsChange).toBeCalled();
expect(mockData.onCollapseColumnsChange).not.toBeCalled();
const startIndexOfColumnSection = mockData.metricSlice.length + 3;
rerender(
<DatasourcePanelItem
index={startIndexOfColumnSection}
data={mockData}
style={{}}
/>,
);
fireEvent.click(getByRole('button'));
expect(mockData.onCollapseColumnsChange).toBeCalled();
rerender(
<DatasourcePanelItem
index={1}
data={{
...mockData,
collapseMetrics: true,
}}
style={{}}
/>,
);
expect(
queryByText(
`Showing ${mockData.metricSlice.length} of ${mockData.totalMetrics}`,
),
).not.toBeInTheDocument();
rerender(
<DatasourcePanelItem
index={2}
data={{
...mockData,
collapseMetrics: true,
}}
style={{}}
/>,
);
expect(queryByText('Columns')).toBeInTheDocument();
});

View File

@ -0,0 +1,234 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { CSSProperties } from 'react';
import { css, Metric, styled, t, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import DatasourcePanelDragOption from './DatasourcePanelDragOption';
import { DndItemType } from '../DndItemType';
import { DndItemValue } from './types';
export type DataSourcePanelColumn = {
is_dttm?: boolean | null;
description?: string | null;
expression?: string | null;
is_certified?: number | null;
column_name?: string | null;
name?: string | null;
type?: string;
};
type Props = {
index: number;
style: CSSProperties;
data: {
metricSlice: Metric[];
columnSlice: DataSourcePanelColumn[];
totalMetrics: number;
totalColumns: number;
width: number;
showAllMetrics: boolean;
onShowAllMetricsChange: (showAll: boolean) => void;
showAllColumns: boolean;
onShowAllColumnsChange: (showAll: boolean) => void;
collapseMetrics: boolean;
onCollapseMetricsChange: (collapse: boolean) => void;
collapseColumns: boolean;
onCollapseColumnsChange: (collapse: boolean) => void;
};
};
export const DEFAULT_MAX_COLUMNS_LENGTH = 50;
export const DEFAULT_MAX_METRICS_LENGTH = 50;
export const ITEM_HEIGHT = 30;
const Button = styled.button`
background: none;
border: none;
text-decoration: underline;
color: ${({ theme }) => theme.colors.primary.dark1};
`;
const ButtonContainer = styled.div`
text-align: center;
padding-top: 2px;
`;
const LabelWrapper = styled.div`
${({ theme }) => css`
overflow: hidden;
text-overflow: ellipsis;
font-size: ${theme.typography.sizes.s}px;
background-color: ${theme.colors.grayscale.light4};
margin: ${theme.gridUnit * 2}px 0;
border-radius: 4px;
padding: 0 ${theme.gridUnit}px;
&:first-of-type {
margin-top: 0;
}
&:last-of-type {
margin-bottom: 0;
}
padding: 0;
cursor: pointer;
&:hover {
background-color: ${theme.colors.grayscale.light3};
}
& > span {
white-space: nowrap;
}
.option-label {
display: inline;
}
.metric-option {
& > svg {
min-width: ${theme.gridUnit * 4}px;
}
& > .option-label {
overflow: hidden;
text-overflow: ellipsis;
}
}
`}
`;
const SectionHeaderButton = styled.button`
display: flex;
justify-content: space-between;
align-items: center;
border: none;
background: transparent;
width: 100%;
padding-inline: 0px;
`;
const SectionHeader = styled.span`
${({ theme }) => `
font-size: ${theme.typography.sizes.m}px;
line-height: 1.3;
`}
`;
const DatasourcePanelItem: React.FC<Props> = ({ index, style, data }) => {
const {
metricSlice: _metricSlice,
columnSlice,
totalMetrics,
totalColumns,
width,
showAllMetrics,
onShowAllMetricsChange,
showAllColumns,
onShowAllColumnsChange,
collapseMetrics,
onCollapseMetricsChange,
collapseColumns,
onCollapseColumnsChange,
} = data;
const metricSlice = collapseMetrics ? [] : _metricSlice;
const EXTRA_LINES = collapseMetrics ? 1 : 2;
const isColumnSection = collapseMetrics
? index >= 1
: index > metricSlice.length + EXTRA_LINES;
const HEADER_LINE = isColumnSection
? metricSlice.length + EXTRA_LINES + 1
: 0;
const SUBTITLE_LINE = HEADER_LINE + 1;
const BOTTOM_LINE =
(isColumnSection ? columnSlice.length : metricSlice.length) +
(collapseMetrics ? HEADER_LINE : SUBTITLE_LINE) +
1;
const collapsed = isColumnSection ? collapseColumns : collapseMetrics;
const setCollapse = isColumnSection
? onCollapseColumnsChange
: onCollapseMetricsChange;
const showAll = isColumnSection ? showAllColumns : showAllMetrics;
const setShowAll = isColumnSection
? onShowAllColumnsChange
: onShowAllMetricsChange;
const theme = useTheme();
return (
<div
style={style}
css={css`
padding: 0 ${theme.gridUnit * 4}px;
`}
>
{index === HEADER_LINE && (
<SectionHeaderButton onClick={() => setCollapse(!collapsed)}>
<SectionHeader>
{isColumnSection ? t('Columns') : t('Metrics')}
</SectionHeader>
{collapsed ? (
<Icons.DownOutlined iconSize="s" />
) : (
<Icons.UpOutlined iconSize="s" />
)}
</SectionHeaderButton>
)}
{index === SUBTITLE_LINE && !collapsed && (
<div className="field-length">
{isColumnSection
? t(`Showing %s of %s`, columnSlice?.length, totalColumns)
: t(`Showing %s of %s`, metricSlice?.length, totalMetrics)}
</div>
)}
{index > SUBTITLE_LINE && index < BOTTOM_LINE && (
<LabelWrapper
key={
(isColumnSection
? columnSlice[index - SUBTITLE_LINE - 1].column_name
: metricSlice[index - SUBTITLE_LINE - 1].metric_name) +
String(width)
}
className="column"
>
<DatasourcePanelDragOption
value={
isColumnSection
? (columnSlice[index - SUBTITLE_LINE - 1] as DndItemValue)
: metricSlice[index - SUBTITLE_LINE - 1]
}
type={isColumnSection ? DndItemType.Column : DndItemType.Metric}
/>
</LabelWrapper>
)}
{index === BOTTOM_LINE &&
!collapsed &&
(isColumnSection
? totalColumns > DEFAULT_MAX_COLUMNS_LENGTH
: totalMetrics > DEFAULT_MAX_METRICS_LENGTH) && (
<ButtonContainer>
<Button onClick={() => setShowAll(!showAll)}>
{showAll ? t('Show less...') : t('Show all...')}
</Button>
</ButtonContainer>
)}
</div>
);
};
export default DatasourcePanelItem;

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
css,
DatasourceType,
@ -27,10 +27,11 @@ import {
} from '@superset-ui/core';
import { ControlConfig } from '@superset-ui/chart-controls';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { debounce, isArray } from 'lodash';
import { matchSorter, rankings } from 'match-sorter';
import Collapse from 'src/components/Collapse';
import Alert from 'src/components/Alert';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
@ -38,23 +39,16 @@ import { Input } from 'src/components/Input';
import { FAST_DEBOUNCE } from 'src/constants';
import { ExploreActions } from 'src/explore/actions/exploreActions';
import Control from 'src/explore/components/Control';
import DatasourcePanelDragOption from './DatasourcePanelDragOption';
import { DndItemType } from '../DndItemType';
import { DndItemValue } from './types';
import DatasourcePanelItem, {
ITEM_HEIGHT,
DataSourcePanelColumn,
DEFAULT_MAX_COLUMNS_LENGTH,
DEFAULT_MAX_METRICS_LENGTH,
} from './DatasourcePanelItem';
interface DatasourceControl extends ControlConfig {
datasource?: IDatasource;
}
export interface DataSourcePanelColumn {
is_dttm?: boolean | null;
description?: string | null;
expression?: string | null;
is_certified?: number | null;
column_name?: string | null;
name?: string | null;
type?: string;
}
export interface IDatasource {
metrics: Metric[];
columns: DataSourcePanelColumn[];
@ -76,22 +70,10 @@ export interface Props {
};
actions: Partial<ExploreActions> & Pick<ExploreActions, 'setControlValue'>;
// we use this props control force update when this panel resize
shouldForceUpdate?: number;
width: number;
formData?: QueryFormData;
}
const Button = styled.button`
background: none;
border: none;
text-decoration: underline;
color: ${({ theme }) => theme.colors.primary.dark1};
`;
const ButtonContainer = styled.div`
text-align: center;
padding-top: 2px;
`;
const DatasourceContainer = styled.div`
${({ theme }) => css`
background-color: ${theme.colors.grayscale.light5};
@ -104,8 +86,9 @@ const DatasourceContainer = styled.div`
height: auto;
}
.field-selections {
padding: 0 0 ${4 * theme.gridUnit}px;
padding: 0 0 ${theme.gridUnit}px;
overflow: auto;
height: 100%;
}
.field-length {
margin-bottom: ${theme.gridUnit * 2}px;
@ -127,56 +110,6 @@ const DatasourceContainer = styled.div`
`};
`;
const LabelWrapper = styled.div`
${({ theme }) => css`
overflow: hidden;
text-overflow: ellipsis;
font-size: ${theme.typography.sizes.s}px;
background-color: ${theme.colors.grayscale.light4};
margin: ${theme.gridUnit * 2}px 0;
border-radius: 4px;
padding: 0 ${theme.gridUnit}px;
&:first-of-type {
margin-top: 0;
}
&:last-of-type {
margin-bottom: 0;
}
padding: 0;
cursor: pointer;
&:hover {
background-color: ${theme.colors.grayscale.light3};
}
& > span {
white-space: nowrap;
}
.option-label {
display: inline;
}
.metric-option {
& > svg {
min-width: ${theme.gridUnit * 4}px;
}
& > .option-label {
overflow: hidden;
text-overflow: ellipsis;
}
}
`}
`;
const SectionHeader = styled.span`
${({ theme }) => `
font-size: ${theme.typography.sizes.m}px;
line-height: 1.3;
`}
`;
const StyledInfoboxWrapper = styled.div`
${({ theme }) => css`
margin: 0 ${theme.gridUnit * 2.5}px;
@ -187,27 +120,14 @@ const StyledInfoboxWrapper = styled.div`
`}
`;
const LabelContainer = (props: {
children: React.ReactElement;
className: string;
}) => {
const labelRef = useRef<HTMLDivElement>(null);
const extendedProps = {
labelRef,
};
return (
<LabelWrapper className={props.className}>
{React.cloneElement(props.children, extendedProps)}
</LabelWrapper>
);
};
const BORDER_WIDTH = 2;
export default function DataSourcePanel({
datasource,
formData,
controls: { datasource: datasourceControl },
actions,
shouldForceUpdate,
width,
}: Props) {
const { columns: _columns, metrics } = datasource;
// display temporal column first
@ -233,9 +153,8 @@ export default function DataSourcePanel({
});
const [showAllMetrics, setShowAllMetrics] = useState(false);
const [showAllColumns, setShowAllColumns] = useState(false);
const DEFAULT_MAX_COLUMNS_LENGTH = 50;
const DEFAULT_MAX_METRICS_LENGTH = 50;
const [collapseMetrics, setCollapseMetrics] = useState(false);
const [collapseColumns, setCollapseColumns] = useState(false);
const search = useMemo(
() =>
@ -385,78 +304,40 @@ export default function DataSourcePanel({
/>
</StyledInfoboxWrapper>
)}
<Collapse
defaultActiveKey={['metrics', 'column']}
expandIconPosition="right"
ghost
>
{metrics?.length && (
<Collapse.Panel
header={<SectionHeader>{t('Metrics')}</SectionHeader>}
key="metrics"
<AutoSizer>
{({ height }) => (
<List
width={width - BORDER_WIDTH}
height={height}
itemSize={ITEM_HEIGHT}
itemCount={
(collapseMetrics ? 0 : metricSlice?.length) +
(collapseColumns ? 0 : columnSlice.length) +
2 + // Each section header row
(collapseMetrics ? 0 : 2) +
(collapseColumns ? 0 : 2)
}
itemData={{
metricSlice,
columnSlice,
width,
totalMetrics: lists?.metrics.length,
totalColumns: lists?.columns.length,
showAllMetrics,
onShowAllMetricsChange: setShowAllMetrics,
showAllColumns,
onShowAllColumnsChange: setShowAllColumns,
collapseMetrics,
onCollapseMetricsChange: setCollapseMetrics,
collapseColumns,
onCollapseColumnsChange: setCollapseColumns,
}}
overscanCount={5}
>
<div className="field-length">
{t(
`Showing %s of %s`,
metricSlice?.length,
lists?.metrics.length,
)}
</div>
{metricSlice?.map?.((m: Metric) => (
<LabelContainer
key={m.metric_name + String(shouldForceUpdate)}
className="column"
>
<DatasourcePanelDragOption
value={m}
type={DndItemType.Metric}
/>
</LabelContainer>
))}
{lists?.metrics?.length > DEFAULT_MAX_METRICS_LENGTH ? (
<ButtonContainer>
<Button onClick={() => setShowAllMetrics(!showAllMetrics)}>
{showAllMetrics ? t('Show less...') : t('Show all...')}
</Button>
</ButtonContainer>
) : (
<></>
)}
</Collapse.Panel>
{DatasourcePanelItem}
</List>
)}
<Collapse.Panel
header={<SectionHeader>{t('Columns')}</SectionHeader>}
key="column"
>
<div className="field-length">
{t(
`Showing %s of %s`,
columnSlice.length,
lists.columns.length,
)}
</div>
{columnSlice.map(col => (
<LabelContainer
key={col.column_name + String(shouldForceUpdate)}
className="column"
>
<DatasourcePanelDragOption
value={col as DndItemValue}
type={DndItemType.Column}
/>
</LabelContainer>
))}
{lists.columns.length > DEFAULT_MAX_COLUMNS_LENGTH ? (
<ButtonContainer>
<Button onClick={() => setShowAllColumns(!showAllColumns)}>
{showAllColumns ? t('Show Less...') : t('Show all...')}
</Button>
</ButtonContainer>
) : (
<></>
)}
</Collapse.Panel>
</Collapse>
</AutoSizer>
</div>
</>
),
@ -470,8 +351,10 @@ export default function DataSourcePanel({
search,
showAllColumns,
showAllMetrics,
collapseMetrics,
collapseColumns,
datasourceIsSaveable,
shouldForceUpdate,
width,
],
);

View File

@ -229,6 +229,20 @@ const updateHistory = debounce(
1000,
);
const defaultSidebarsWidth = {
controls_width: 320,
datasource_width: 300,
};
function getSidebarWidths(key) {
return getItem(key, defaultSidebarsWidth[key]);
}
function setSidebarWidths(key, dimension) {
const newDimension = Number(getSidebarWidths(key)) + dimension.width;
setItem(key, newDimension);
}
function ExploreViewContainer(props) {
const dynamicPluginContext = usePluginContext();
const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType];
@ -243,16 +257,13 @@ function ExploreViewContainer(props) {
);
const [isCollapsed, setIsCollapsed] = useState(false);
const [shouldForceUpdate, setShouldForceUpdate] = useState(-1);
const [width, setWidth] = useState(
getSidebarWidths(LocalStorageKeys.DatasourceWidth),
);
const tabId = useTabId();
const theme = useTheme();
const defaultSidebarsWidth = {
controls_width: 320,
datasource_width: 300,
};
const addHistory = useCallback(
async ({ isReplace = false, title } = {}) => {
const formData = props.dashboardId
@ -534,15 +545,6 @@ function ExploreViewContainer(props) {
);
}
function getSidebarWidths(key) {
return getItem(key, defaultSidebarsWidth[key]);
}
function setSidebarWidths(key, dimension) {
const newDimension = Number(getSidebarWidths(key)) + dimension.width;
setItem(key, newDimension);
}
if (props.standalone) {
return renderChartContainer();
}
@ -593,7 +595,7 @@ function ExploreViewContainer(props) {
/>
<Resizable
onResizeStop={(evt, direction, ref, d) => {
setShouldForceUpdate(d?.width);
setWidth(ref.getBoundingClientRect().width);
setSidebarWidths(LocalStorageKeys.DatasourceWidth, d);
}}
defaultSize={{
@ -627,7 +629,7 @@ function ExploreViewContainer(props) {
datasource={props.datasource}
controls={props.controls}
actions={props.actions}
shouldForceUpdate={shouldForceUpdate}
width={width}
user={props.user}
/>
</Resizable>