chore: Create a generic header component for Explore and Dashboard (#20044)

* chore: Create a generic header component for Explore and Dashboard

* Add tests

* Fix undefined error

* Remove duplicate code

* Fix cypress test
This commit is contained in:
Kamil Gabryjelski
2022-05-13 15:36:18 +02:00
committed by GitHub
parent b53daa91ec
commit 1cd002e801
13 changed files with 730 additions and 685 deletions

View File

@@ -1,68 +0,0 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
import { ChartEditableTitle } from './index';
const createProps = (overrides: Record<string, any> = {}) => ({
title: 'Chart title',
placeholder: 'Add the name of the chart',
canEdit: true,
onSave: jest.fn(),
...overrides,
});
describe('Chart editable title', () => {
it('renders chart title', () => {
const props = createProps();
render(<ChartEditableTitle {...props} />);
expect(screen.getByText('Chart title')).toBeVisible();
});
it('renders placeholder', () => {
const props = createProps({
title: '',
});
render(<ChartEditableTitle {...props} />);
expect(screen.getByText('Add the name of the chart')).toBeVisible();
});
it('click, edit and save title', () => {
const props = createProps();
render(<ChartEditableTitle {...props} />);
const textboxElement = screen.getByRole('textbox');
userEvent.click(textboxElement);
userEvent.type(textboxElement, ' edited');
expect(screen.getByText('Chart title edited')).toBeVisible();
userEvent.type(textboxElement, '{enter}');
expect(props.onSave).toHaveBeenCalled();
});
it('renders in non-editable mode', () => {
const props = createProps({ canEdit: false });
render(<ChartEditableTitle {...props} />);
const titleElement = screen.getByLabelText('Chart title');
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(titleElement).toBeVisible();
userEvent.click(titleElement);
userEvent.type(titleElement, ' edited{enter}');
expect(props.onSave).not.toHaveBeenCalled();
});
});

View File

@@ -1,213 +0,0 @@
/**
* 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, {
ChangeEvent,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { css, styled, t } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import { useResizeDetector } from 'react-resize-detector';
export type ChartEditableTitleProps = {
title: string;
placeholder: string;
onSave: (title: string) => void;
canEdit: boolean;
};
const Styles = styled.div`
${({ theme }) => css`
display: flex;
font-size: ${theme.typography.sizes.xl}px;
font-weight: ${theme.typography.weights.bold};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
& .chart-title,
& .chart-title-input {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& .chart-title {
cursor: default;
}
& .chart-title-input {
border: none;
padding: 0;
outline: none;
&::placeholder {
color: ${theme.colors.grayscale.light1};
}
}
& .input-sizer {
position: absolute;
left: -9999px;
display: inline-block;
}
`}
`;
export const ChartEditableTitle = ({
title,
placeholder,
onSave,
canEdit,
}: ChartEditableTitleProps) => {
const [isEditing, setIsEditing] = useState(false);
const [currentTitle, setCurrentTitle] = useState(title || '');
const contentRef = useRef<HTMLInputElement>(null);
const [showTooltip, setShowTooltip] = useState(false);
const { width: inputWidth, ref: sizerRef } = useResizeDetector();
const { width: containerWidth, ref: containerRef } = useResizeDetector({
refreshMode: 'debounce',
});
useEffect(() => {
if (isEditing && contentRef?.current) {
contentRef.current.focus();
// move cursor and scroll to the end
if (contentRef.current.setSelectionRange) {
const { length } = contentRef.current.value;
contentRef.current.setSelectionRange(length, length);
contentRef.current.scrollLeft = contentRef.current.scrollWidth;
}
}
}, [isEditing]);
// a trick to make the input grow when user types text
// we make additional span component, place it somewhere out of view and copy input
// then we can measure the width of that span to resize the input element
useLayoutEffect(() => {
if (sizerRef?.current) {
sizerRef.current.innerHTML = (currentTitle || placeholder).replace(
/\s/g,
'&nbsp;',
);
}
}, [currentTitle, placeholder, sizerRef]);
useEffect(() => {
if (
contentRef.current &&
contentRef.current.scrollWidth > contentRef.current.clientWidth
) {
setShowTooltip(true);
} else {
setShowTooltip(false);
}
}, [inputWidth, containerWidth]);
const handleClick = useCallback(() => {
if (!canEdit || isEditing) {
return;
}
setIsEditing(true);
}, [canEdit, isEditing]);
const handleBlur = useCallback(() => {
if (!canEdit) {
return;
}
const formattedTitle = currentTitle.trim();
setCurrentTitle(formattedTitle);
if (title !== formattedTitle) {
onSave(formattedTitle);
}
setIsEditing(false);
}, [canEdit, currentTitle, onSave, title]);
const handleChange = useCallback(
(ev: ChangeEvent<HTMLInputElement>) => {
if (!canEdit || !isEditing) {
return;
}
setCurrentTitle(ev.target.value);
},
[canEdit, isEditing],
);
const handleKeyPress = useCallback(
(ev: KeyboardEvent<HTMLInputElement>) => {
if (!canEdit) {
return;
}
if (ev.key === 'Enter') {
ev.preventDefault();
contentRef.current?.blur();
}
},
[canEdit],
);
return (
<Styles ref={containerRef}>
<Tooltip
id="title-tooltip"
title={showTooltip && currentTitle && !isEditing ? currentTitle : null}
>
{canEdit ? (
<input
data-test="editable-title-input"
className="chart-title-input"
aria-label={t('Chart title')}
ref={contentRef}
onChange={handleChange}
onBlur={handleBlur}
onClick={handleClick}
onKeyPress={handleKeyPress}
placeholder={placeholder}
value={currentTitle}
css={css`
cursor: ${isEditing ? 'text' : 'pointer'};
${inputWidth &&
inputWidth > 0 &&
css`
width: ${inputWidth}px;
`}
`}
/>
) : (
<span
className="chart-title"
aria-label={t('Chart title')}
ref={contentRef}
>
{currentTitle}
</span>
)}
</Tooltip>
<span ref={sizerRef} className="input-sizer" aria-hidden tabIndex={-1} />
</Styles>
);
};

View File

@@ -18,10 +18,13 @@
*/
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import sinon from 'sinon';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import * as chartAction from 'src/components/Chart/chartAction';
import * as downloadAsImage from 'src/utils/downloadAsImage';
import * as exploreUtils from 'src/explore/exploreUtils';
import ExploreHeader from '.';
const chartEndpoint = 'glob:*api/v1/chart/*';
@@ -30,6 +33,7 @@ fetchMock.get(chartEndpoint, { json: 'foo' });
const createProps = () => ({
chart: {
id: 1,
latestQueryFormData: {
viz_type: 'histogram',
datasource: '49__table',
@@ -88,17 +92,29 @@ const createProps = () => ({
},
slice_name: 'Age distribution of respondents',
actions: {
postChartFormData: () => null,
updateChartTitle: () => null,
fetchFaveStar: () => null,
saveFaveStar: () => null,
postChartFormData: jest.fn(),
updateChartTitle: jest.fn(),
fetchFaveStar: jest.fn(),
saveFaveStar: jest.fn(),
redirectSQLLab: jest.fn(),
},
user: {
userId: 1,
},
onSaveChart: jest.fn(),
canOverwrite: false,
canDownload: false,
isStarred: false,
});
fetchMock.post(
'http://api/v1/chart/data?form_data=%7B%22slice_id%22%3A318%7D',
{ body: {} },
{
sendAsJson: false,
},
);
test('Cancelling changes to the properties should reset previous properties', () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
@@ -136,3 +152,208 @@ test('Save disabled', () => {
userEvent.click(screen.getByText('Save'));
expect(props.onSaveChart).not.toHaveBeenCalled();
});
describe('Additional actions tests', () => {
test('Should render a button', () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
expect(screen.getByLabelText('Menu actions trigger')).toBeInTheDocument();
});
test('Should open a menu', () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(screen.getByText('Edit chart properties')).toBeInTheDocument();
expect(screen.getByText('Download')).toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
expect(screen.getByText('View query')).toBeInTheDocument();
expect(screen.getByText('Run in SQL Lab')).toBeInTheDocument();
expect(
screen.queryByText('Set up an email report'),
).not.toBeInTheDocument();
expect(screen.queryByText('Manage email report')).not.toBeInTheDocument();
});
test('Should open download submenu', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(screen.queryByText('Export to .CSV')).not.toBeInTheDocument();
expect(screen.queryByText('Export to .JSON')).not.toBeInTheDocument();
expect(screen.queryByText('Download as image')).not.toBeInTheDocument();
expect(screen.getByText('Download')).toBeInTheDocument();
userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
expect(await screen.findByText('Export to .JSON')).toBeInTheDocument();
expect(await screen.findByText('Download as image')).toBeInTheDocument();
});
test('Should open share submenu', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(
screen.queryByText('Copy permalink to clipboard'),
).not.toBeInTheDocument();
expect(screen.queryByText('Embed code')).not.toBeInTheDocument();
expect(screen.queryByText('Share chart by email')).not.toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
userEvent.hover(screen.getByText('Share'));
expect(
await screen.findByText('Copy permalink to clipboard'),
).toBeInTheDocument();
expect(await screen.findByText('Embed code')).toBeInTheDocument();
expect(await screen.findByText('Share chart by email')).toBeInTheDocument();
});
test('Should call onOpenPropertiesModal when click on "Edit chart properties"', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
expect(props.actions.redirectSQLLab).toBeCalledTimes(0);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.click(
screen.getByRole('menuitem', { name: 'Edit chart properties' }),
);
expect(await screen.findByText('Edit Chart Properties')).toBeVisible();
});
test('Should call getChartDataRequest when click on "View query"', async () => {
const props = createProps();
const getChartDataRequest = jest.spyOn(chartAction, 'getChartDataRequest');
render(<ExploreHeader {...props} />, {
useRedux: true,
});
expect(getChartDataRequest).toBeCalledTimes(0);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(getChartDataRequest).toBeCalledTimes(0);
const menuItem = screen.getByText('View query').parentElement!;
userEvent.click(menuItem);
await waitFor(() => expect(getChartDataRequest).toBeCalledTimes(1));
});
test('Should call onOpenInEditor when click on "Run in SQL Lab"', () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
expect(props.actions.redirectSQLLab).toBeCalledTimes(0);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(props.actions.redirectSQLLab).toBeCalledTimes(0);
userEvent.click(screen.getByRole('menuitem', { name: 'Run in SQL Lab' }));
expect(props.actions.redirectSQLLab).toBeCalledTimes(1);
});
describe('Download', () => {
let spyDownloadAsImage = sinon.spy();
let spyExportChart = sinon.spy();
beforeEach(() => {
spyDownloadAsImage = sinon.spy(downloadAsImage, 'default');
spyExportChart = sinon.spy(exploreUtils, 'exportChart');
});
afterEach(() => {
spyDownloadAsImage.restore();
spyExportChart.restore();
});
test('Should call downloadAsImage when click on "Download as image"', async () => {
const props = createProps();
const spy = jest.spyOn(downloadAsImage, 'default');
render(<ExploreHeader {...props} />, {
useRedux: true,
});
expect(spy).toBeCalledTimes(0);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(spy).toBeCalledTimes(0);
userEvent.hover(screen.getByText('Download'));
const downloadAsImageElement = await screen.findByText(
'Download as image',
);
userEvent.click(downloadAsImageElement);
expect(spy).toBeCalledTimes(1);
});
test('Should not export to CSV if canDownload=false', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
const exportCSVElement = await screen.findByText('Export to .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(0);
spyExportChart.restore();
});
test('Should export to CSV if canDownload=true', async () => {
const props = createProps();
props.canDownload = true;
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
const exportCSVElement = await screen.findByText('Export to .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(1);
spyExportChart.restore();
});
test('Should export to JSON', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
const exportJsonElement = await screen.findByText('Export to .JSON');
userEvent.click(exportJsonElement);
expect(spyExportChart.callCount).toBe(1);
});
test('Should export to pivoted CSV if canDownloadCSV=true and viz_type=pivot_table_v2', async () => {
const props = createProps();
props.canDownload = true;
props.chart.latestQueryFormData.viz_type = 'pivot_table_v2';
render(<ExploreHeader {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(screen.getByText('Download'));
const exportCSVElement = await screen.findByText(
'Export to pivoted .CSV',
);
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(1);
});
});
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import PropTypes from 'prop-types';
@@ -30,14 +30,12 @@ import {
import { toggleActive, deleteActiveReport } from 'src/reports/actions/reports';
import { chartPropShape } from 'src/dashboard/util/propShapes';
import AlteredSliceTag from 'src/components/AlteredSliceTag';
import FaveStar from 'src/components/FaveStar';
import Button from 'src/components/Button';
import Icons from 'src/components/Icons';
import PropertiesModal from 'src/explore/components/PropertiesModal';
import { sliceUpdated } from 'src/explore/actions/exploreActions';
import CertifiedBadge from 'src/components/CertifiedBadge';
import ExploreAdditionalActionsMenu from '../ExploreAdditionalActionsMenu';
import { ChartEditableTitle } from './ChartEditableTitle';
import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions';
import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu';
const propTypes = {
actions: PropTypes.object.isRequired,
@@ -48,7 +46,7 @@ const propTypes = {
slice: PropTypes.object,
sliceName: PropTypes.string,
table_name: PropTypes.string,
form_data: PropTypes.object,
formData: PropTypes.object,
ownState: PropTypes.object,
timeout: PropTypes.number,
chart: chartPropShape,
@@ -62,70 +60,25 @@ const saveButtonStyles = theme => css`
}
`;
const headerStyles = theme => css`
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
justify-content: space-between;
height: 100%;
export const ExploreChartHeader = ({
dashboardId,
slice,
actions,
formData,
chart,
user,
canOverwrite,
canDownload,
isStarred,
sliceUpdated,
sliceName,
onSaveChart,
saveDisabled,
}) => {
const { latestQueryFormData, sliceFormData } = chart;
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
span[role='button'] {
display: flex;
height: 100%;
}
.title-panel {
display: flex;
align-items: center;
min-width: 0;
margin-right: ${theme.gridUnit * 12}px;
}
.right-button-panel {
display: flex;
align-items: center;
}
`;
const buttonsStyles = theme => css`
display: flex;
align-items: center;
padding-left: ${theme.gridUnit * 2}px;
& .fave-unfave-icon {
padding: 0 ${theme.gridUnit}px;
&:first-child {
padding-left: 0;
}
}
`;
const saveButtonContainerStyles = theme => css`
margin-right: ${theme.gridUnit * 2}px;
`;
export class ExploreChartHeader extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
isPropertiesModalOpen: false,
};
this.openPropertiesModal = this.openPropertiesModal.bind(this);
this.closePropertiesModal = this.closePropertiesModal.bind(this);
this.fetchChartDashboardData = this.fetchChartDashboardData.bind(this);
}
componentDidMount() {
const { dashboardId } = this.props;
if (dashboardId) {
this.fetchChartDashboardData();
}
}
async fetchChartDashboardData() {
const { dashboardId, slice } = this.props;
const fetchChartDashboardData = async () => {
await SupersetClient.get({
endpoint: `/api/v1/chart/${slice.slice_id}`,
})
@@ -162,96 +115,71 @@ export class ExploreChartHeader extends React.PureComponent {
}
})
.catch(() => {});
}
};
postChartFormData() {
this.props.actions.postChartFormData(
this.props.form_data,
true,
this.props.timeout,
this.props.chart.id,
this.props.ownState,
);
}
useEffect(() => {
if (dashboardId) {
fetchChartDashboardData();
}
}, []);
openPropertiesModal() {
this.setState({
isPropertiesModalOpen: true,
});
}
const openPropertiesModal = () => {
setIsPropertiesModalOpen(true);
};
closePropertiesModal() {
this.setState({
isPropertiesModalOpen: false,
});
}
const closePropertiesModal = () => {
setIsPropertiesModalOpen(false);
};
render() {
const {
actions,
chart,
user,
formData,
slice,
canOverwrite,
const [menu, isDropdownVisible, setIsDropdownVisible] =
useExploreAdditionalActionsMenu(
latestQueryFormData,
canDownload,
isStarred,
sliceUpdated,
sliceName,
onSaveChart,
saveDisabled,
} = this.props;
const { latestQueryFormData, sliceFormData } = chart;
const oldSliceName = slice?.slice_name;
return (
<div id="slice-header" css={headerStyles}>
<div className="title-panel">
<ChartEditableTitle
title={sliceName}
canEdit={
!slice ||
canOverwrite ||
(slice?.owners || []).includes(user?.userId)
}
onSave={actions.updateChartTitle}
placeholder={t('Add the name of the chart')}
/>
{slice && (
<span css={buttonsStyles}>
{slice.certified_by && (
<CertifiedBadge
certifiedBy={slice.certified_by}
details={slice.certification_details}
/>
)}
{user.userId && (
<FaveStar
itemId={slice.slice_id}
fetchFaveStar={actions.fetchFaveStar}
saveFaveStar={actions.saveFaveStar}
isStarred={isStarred}
showTooltip
/>
)}
{this.state.isPropertiesModalOpen && (
<PropertiesModal
show={this.state.isPropertiesModalOpen}
onHide={this.closePropertiesModal}
onSave={sliceUpdated}
slice={slice}
/>
)}
{sliceFormData && (
<AlteredSliceTag
className="altered"
origFormData={{ ...sliceFormData, chartTitle: oldSliceName }}
currentFormData={{ ...formData, chartTitle: sliceName }}
/>
)}
</span>
)}
</div>
<div className="right-button-panel">
slice,
actions.redirectSQLLab,
openPropertiesModal,
);
const oldSliceName = slice?.slice_name;
return (
<>
<PageHeaderWithActions
editableTitleProps={{
title: sliceName,
canEdit:
!slice ||
canOverwrite ||
(slice?.owners || []).includes(user?.userId),
onSave: actions.updateChartTitle,
placeholder: t('Add the name of the chart'),
label: t('Chart title'),
}}
showTitlePanelItems={!!slice}
certificatiedBadgeProps={{
certifiedBy: slice?.certified_by,
details: slice?.certification_details,
}}
showFaveStar={!!user?.userId}
faveStarProps={{
itemId: slice?.slice_id,
fetchFaveStar: actions.fetchFaveStar,
saveFaveStar: actions.saveFaveStar,
isStarred,
showTooltip: true,
}}
titlePanelAdditionalItems={
sliceFormData ? (
<AlteredSliceTag
className="altered"
origFormData={{
...sliceFormData,
chartTitle: oldSliceName,
}}
currentFormData={{ ...formData, chartTitle: sliceName }}
/>
) : null
}
rightPanelAdditionalItems={
<Tooltip
title={
saveDisabled
@@ -260,7 +188,7 @@ export class ExploreChartHeader extends React.PureComponent {
}
>
{/* needed to wrap button in a div - antd tooltip doesn't work with disabled button */}
<div css={saveButtonContainerStyles}>
<div>
<Button
buttonStyle="secondary"
onClick={onSaveChart}
@@ -273,18 +201,24 @@ export class ExploreChartHeader extends React.PureComponent {
</Button>
</div>
</Tooltip>
<ExploreAdditionalActionsMenu
onOpenInEditor={actions.redirectSQLLab}
onOpenPropertiesModal={this.openPropertiesModal}
slice={slice}
canDownloadCSV={canDownload}
latestQueryFormData={latestQueryFormData}
/>
</div>
</div>
);
}
}
}
additionalActionsMenu={menu}
menuDropdownProps={{
visible: isDropdownVisible,
onVisibleChange: setIsDropdownVisible,
}}
/>
{isPropertiesModalOpen && (
<PropertiesModal
show={isPropertiesModalOpen}
onHide={closePropertiesModal}
onSave={sliceUpdated}
slice={slice}
/>
)}
</>
);
};
ExploreChartHeader.propTypes = propTypes;