chore: Moves messageToasts to the components folder (#14431)

* chore: Moves messageToasts to the components folder

* Rebases
This commit is contained in:
Michael S. Molina
2021-09-22 07:44:54 -03:00
committed by GitHub
parent b6d78bf4f2
commit 9b17e86b44
83 changed files with 179 additions and 237 deletions

View File

@@ -0,0 +1,62 @@
/**
* 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 { mount } from 'enzyme';
import { ThemeProvider, supersetTheme } from '@superset-ui/core';
import Toast from 'src/components/MessageToasts/Toast';
import { act } from 'react-dom/test-utils';
import mockMessageToasts from './mockMessageToasts';
const props = {
toast: mockMessageToasts[0],
onCloseToast() {},
};
const setup = overrideProps =>
mount(<Toast {...props} {...overrideProps} />, {
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
describe('Toast', () => {
it('should render', () => {
const wrapper = setup();
expect(wrapper.find('[data-test="toast-container"]')).toExist();
});
it('should render toastText within the div', () => {
const wrapper = setup();
const container = wrapper.find('[data-test="toast-container"]');
expect(container.hostNodes().childAt(1).text()).toBe(props.toast.text);
});
it('should call onCloseToast upon toast dismissal', async () =>
act(
() =>
new Promise(done => {
const onCloseToast = id => {
expect(id).toBe(props.toast.id);
done();
};
const wrapper = setup({ onCloseToast });
wrapper.find('[data-test="close-button"]').props().onClick();
}),
));
});

View File

@@ -0,0 +1,111 @@
/**
* 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 { styled, css, SupersetTheme } from '@superset-ui/core';
import cx from 'classnames';
import Interweave from 'interweave';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Icons from 'src/components/Icons';
import { ToastType, ToastMeta } from './types';
const ToastContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
span {
padding: 0 11px;
}
`;
const StyledIcon = (theme: SupersetTheme) => css`
min-width: ${theme.gridUnit * 5}px;
color: ${theme.colors.grayscale.base};
`;
interface ToastPresenterProps {
toast: ToastMeta;
onCloseToast: (id: string) => void;
}
export default function Toast({ toast, onCloseToast }: ToastPresenterProps) {
const hideTimer = useRef<ReturnType<typeof setTimeout>>();
const [visible, setVisible] = useState(false);
const showToast = () => {
setVisible(true);
};
const handleClosePress = useCallback(() => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
// Wait for the transition
setVisible(() => {
setTimeout(() => {
onCloseToast(toast.id);
}, 150);
return false;
});
}, [onCloseToast, toast.id]);
useEffect(() => {
setTimeout(showToast);
if (toast.duration > 0) {
hideTimer.current = setTimeout(handleClosePress, toast.duration);
}
return () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
};
}, [handleClosePress, toast.duration]);
let className = 'toast--success';
let icon = <Icons.CircleCheckSolid css={theme => StyledIcon(theme)} />;
if (toast.toastType === ToastType.WARNING) {
icon = <Icons.WarningSolid css={StyledIcon} />;
className = 'toast--warning';
} else if (toast.toastType === ToastType.DANGER) {
icon = <Icons.ErrorSolid css={StyledIcon} />;
className = 'toast--danger';
} else if (toast.toastType === ToastType.INFO) {
icon = <Icons.InfoSolid css={StyledIcon} />;
className = 'toast--info';
}
return (
<ToastContainer
className={cx('alert', 'toast', visible && 'toast--visible', className)}
data-test="toast-container"
role="alert"
>
{icon}
<Interweave content={toast.text} />
<i
className="fa fa-close pull-right pointer"
role="button"
tabIndex={0}
onClick={handleClosePress}
aria-label="Close"
data-test="close-button"
/>
</ToastContainer>
);
}

View File

@@ -0,0 +1,28 @@
/**
* 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 { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import ToastPresenter from './ToastPresenter';
import { removeToast } from './actions';
export default connect(
({ messageToasts: toasts }) => ({ toasts }),
dispatch => bindActionCreators({ removeToast }, dispatch),
)(ToastPresenter);

View File

@@ -0,0 +1,51 @@
/**
* 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 { shallow } from 'enzyme';
import Toast from 'src/components/MessageToasts/Toast';
import ToastPresenter from 'src/components/MessageToasts/ToastPresenter';
import mockMessageToasts from './mockMessageToasts';
describe('ToastPresenter', () => {
const props = {
toasts: mockMessageToasts,
removeToast() {},
};
function setup(overrideProps) {
const wrapper = shallow(<ToastPresenter {...props} {...overrideProps} />);
return wrapper;
}
it('should render a div with id toast-presenter', () => {
const wrapper = setup();
expect(wrapper.find('#toast-presenter')).toExist();
});
it('should render a Toast for each toast object', () => {
const wrapper = setup();
expect(wrapper.find(Toast)).toHaveLength(props.toasts.length);
});
it('should pass removeToast to the Toast component', () => {
const removeToast = () => {};
const wrapper = setup({ removeToast });
expect(wrapper.find(Toast).first().prop('onCloseToast')).toBe(removeToast);
});
});

View File

@@ -0,0 +1,89 @@
/**
* 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 { styled } from '@superset-ui/core';
import { ToastMeta } from 'src/components/MessageToasts/types';
import Toast from './Toast';
const StyledToastPresenter = styled.div`
max-width: 600px;
position: fixed;
bottom: 0px;
right: 0px;
margin-right: 50px;
margin-bottom: 50px;
z-index: ${({ theme }) => theme.zIndex.max};
.toast {
background: ${({ theme }) => theme.colors.grayscale.dark1};
border-radius: ${({ theme }) => theme.borderRadius};
box-shadow: 0 2px 4px 0
fade(
${({ theme }) => theme.colors.grayscale.dark2},
${({ theme }) => theme.opacity.mediumLight}
);
color: ${({ theme }) => theme.colors.grayscale.light5};
opacity: 0;
position: relative;
transform: translateY(-100%);
white-space: pre-line;
will-change: transform, opacity;
transition: transform ${({ theme }) => theme.transitionTiming}s,
opacity ${({ theme }) => theme.transitionTiming}s;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 100%;
}
}
.toast > button {
color: ${({ theme }) => theme.colors.grayscale.light5};
opacity: 1;
}
.toast--visible {
opacity: 1;
transform: translateY(0);
}
`;
type ToastPresenterProps = {
toasts: Array<ToastMeta>;
removeToast: () => void;
};
export default function ToastPresenter({
toasts,
removeToast,
}: ToastPresenterProps) {
return (
toasts.length > 0 && (
<StyledToastPresenter id="toast-presenter">
{toasts.map(toast => (
<Toast key={toast.id} toast={toast} onCloseToast={removeToast} />
))}
</StyledToastPresenter>
)
);
}

View File

@@ -0,0 +1,103 @@
/**
* 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 shortid from 'shortid';
import { ToastType, ToastMeta } from './types';
type ToastOptions = Partial<Omit<ToastMeta, 'id' | 'toastType' | 'text'>>;
export function getToastUuid(type: ToastType) {
return `${type}-${shortid.generate()}`;
}
export const ADD_TOAST = 'ADD_TOAST';
export function addToast({
toastType,
text,
duration = 8000,
noDuplicate = false,
}: Omit<ToastMeta, 'id'>) {
return {
type: ADD_TOAST,
payload: {
id: getToastUuid(toastType),
toastType,
text,
duration,
noDuplicate,
},
};
}
export const REMOVE_TOAST = 'REMOVE_TOAST';
export function removeToast(id: string) {
return {
type: REMOVE_TOAST,
payload: {
id,
},
};
}
// Different types of toasts
export const ADD_INFO_TOAST = 'ADD_INFO_TOAST';
export function addInfoToast(text: string, options?: ToastOptions) {
return addToast({
text,
toastType: ToastType.INFO,
duration: 4000,
...options,
});
}
export const ADD_SUCCESS_TOAST = 'ADD_SUCCESS_TOAST';
export function addSuccessToast(text: string, options?: ToastOptions) {
return addToast({
text,
toastType: ToastType.SUCCESS,
duration: 4000,
...options,
});
}
export const ADD_WARNING_TOAST = 'ADD_WARNING_TOAST';
export function addWarningToast(text: string, options?: ToastOptions) {
return addToast({
text,
toastType: ToastType.WARNING,
duration: 6000,
...options,
});
}
export const ADD_DANGER_TOAST = 'ADD_DANGER_TOAST';
export function addDangerToast(text: string, options?: ToastOptions) {
return addToast({
text,
toastType: ToastType.DANGER,
duration: 8000,
...options,
});
}
export const toastActions = {
addInfoToast,
addSuccessToast,
addWarningToast,
addDangerToast,
};

View File

@@ -0,0 +1,40 @@
/**
* 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 { addToast } from './actions';
import { ToastType } from './types';
export default function toastsFromPyFlashMessages(flashMessages = []) {
const toasts = [];
flashMessages.forEach(([messageType, message]) => {
const toastType =
messageType === 'danger'
? ToastType.DANGER
: (messageType === 'success' && ToastType.SUCCESS) || ToastType.INFO;
const toast = addToast({
text: message,
toastType,
}).payload;
toasts.push(toast);
});
return toasts;
}

View File

@@ -0,0 +1,48 @@
/**
* 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 { ToastType } from 'src/components/MessageToasts/types';
import getToastsFromPyFlashMessages from 'src/components/MessageToasts/getToastsFromPyFlashMessages';
describe('getToastsFromPyFlashMessages', () => {
it('should return an info toast', () => {
const toast = getToastsFromPyFlashMessages([['info', 'info test']])[0];
expect(toast).toMatchObject({
toastType: ToastType.INFO,
text: 'info test',
});
});
it('should return a success toast', () => {
const toast = getToastsFromPyFlashMessages([
['success', 'success test'],
])[0];
expect(toast).toMatchObject({
toastType: ToastType.SUCCESS,
text: 'success test',
});
});
it('should return a danger toast', () => {
const toast = getToastsFromPyFlashMessages([['danger', 'danger test']])[0];
expect(toast).toMatchObject({
toastType: ToastType.DANGER,
text: 'danger test',
});
});
});

View File

@@ -0,0 +1,24 @@
/**
* 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 { ToastType } from 'src/components/MessageToasts/types';
export default [
{ id: 'info_id', toastType: ToastType.INFO, text: 'info toast' },
{ id: 'danger_id', toastType: ToastType.DANGER, text: 'danger toast' },
];

View File

@@ -0,0 +1,42 @@
/**
* 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 { ADD_TOAST, REMOVE_TOAST } from './actions';
export default function messageToastsReducer(toasts = [], action) {
switch (action.type) {
case ADD_TOAST: {
const { payload: toast } = action;
const result = toasts.slice();
if (!toast.noDuplicate || !result.find(x => x.text === toast.text)) {
return [toast, ...toasts];
}
return toasts;
}
case REMOVE_TOAST: {
const {
payload: { id },
} = action;
return [...toasts].filter(toast => toast.id !== id);
}
default:
return toasts;
}
}

View File

@@ -0,0 +1,44 @@
/**
* 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 { ADD_TOAST, REMOVE_TOAST } from 'src/components/MessageToasts/actions';
import messageToastsReducer from 'src/components/MessageToasts/reducers';
describe('messageToasts reducer', () => {
it('should return initial state', () => {
expect(messageToastsReducer(undefined, {})).toEqual([]);
});
it('should add a toast', () => {
expect(
messageToastsReducer([], {
type: ADD_TOAST,
payload: { text: 'test', id: 'id', type: 'test_type' },
}),
).toEqual([{ text: 'test', id: 'id', type: 'test_type' }]);
});
it('should remove a toast', () => {
expect(
messageToastsReducer([{ id: 'id' }, { id: 'id2' }], {
type: REMOVE_TOAST,
payload: { id: 'id' },
}),
).toEqual([{ id: 'id2' }]);
});
});

View File

@@ -0,0 +1,34 @@
/**
* 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.
*/
export enum ToastType {
INFO = 'INFO_TOAST',
SUCCESS = 'SUCCESS_TOAST',
WARNING = 'WARNING_TOAST',
DANGER = 'DANGER_TOAST',
}
export interface ToastMeta {
id: string;
toastType: ToastType;
text: string;
duration: number;
/** Whether to skip displaying this message if there are another toast
* with the same message. */
noDuplicate?: boolean;
}

View File

@@ -0,0 +1,57 @@
/**
* 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 { ComponentType, useMemo } from 'react';
import { bindActionCreators } from 'redux';
import { connect, useDispatch } from 'react-redux';
import {
addDangerToast,
addInfoToast,
addSuccessToast,
addWarningToast,
} from './actions';
export interface ToastProps {
addDangerToast: typeof addDangerToast;
addInfoToast: typeof addInfoToast;
addSuccessToast: typeof addSuccessToast;
addWarningToast: typeof addWarningToast;
}
const toasters = {
addInfoToast,
addSuccessToast,
addWarningToast,
addDangerToast,
};
// To work properly the redux state must have a `messageToasts` subtree
export default function withToasts(BaseComponent: ComponentType<any>) {
return connect(null, dispatch => bindActionCreators(toasters, dispatch))(
BaseComponent,
) as any;
// Redux has some confusing typings that cause problems for consumers of this function.
// If someone can fix the types, great, but for now it's just any.
}
export function useToasts() {
const dispatch = useDispatch();
return useMemo(() => bindActionCreators(toasters, dispatch), [dispatch]);
}