Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core] add support for more Alert props #2102

Merged
merged 3 commits into from
Feb 10, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/src/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const ns = "[Blueprint]";
export const CLAMP_MIN_MAX = ns + ` clamp: max cannot be less than min`;

export const ALERT_WARN_CANCEL_PROPS = ns + ` <Alert> cancelButtonText and onCancel should be set together.`;
export const ALERT_WARN_CANCEL_ESCAPE_KEY =
ns + ` <Alert> canEscapeKeyCancel enabled without onCancel or onClose handler.`;
export const ALERT_WARN_CANCEL_OUTSIDE_CLICK =
ns + ` <Alert> canOutsideClickCancel enbaled without onCancel or onClose handler.`;

export const COLLAPSIBLE_LIST_INVALID_CHILD = ns + ` <CollapsibleList> children must be <MenuItem>s`;

Expand Down
98 changes: 79 additions & 19 deletions packages/core/src/components/alert/alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,40 @@ import * as classNames from "classnames";
import * as React from "react";

import { AbstractPureComponent, Classes, Intent, IProps } from "../../common";
import { ALERT_WARN_CANCEL_PROPS } from "../../common/errors";
import {
ALERT_WARN_CANCEL_ESCAPE_KEY,
ALERT_WARN_CANCEL_OUTSIDE_CLICK,
ALERT_WARN_CANCEL_PROPS,
} from "../../common/errors";
import { safeInvoke } from "../../common/utils";
import { Button } from "../button/buttons";
import { Dialog } from "../dialog/dialog";
import { Icon, IconName } from "../icon/icon";

export interface IAlertProps extends IProps {
/**
* Whether pressing <kbd class="pt-key">escape</kbd> when focused on the Alert should cancel the alert.
* If this prop is enabled, then either `onCancel` or `onClose` must also be defined.
* @default false
*/
canEscapeKeyCancel?: boolean;

/**
* Whether clicking outside the Alert should cancel the alert.
* If this prop is enabled, then either `onCancel` or `onClose` must also be defined.
* @default false
*/
canOutsideClickCancel?: boolean;

/**
* The text for the cancel button.
* If this prop is defined, then either `onCancel` or `onClose` must also be defined.
*/
cancelButtonText?: string;

/**
* The text for the confirm (right-most) button.
* This button will always appear, and uses the value of the `intent` prop below.
* @default "OK"
*/
confirmButtonText?: string;
Expand All @@ -45,54 +66,93 @@ export interface IAlertProps extends IProps {
style?: React.CSSProperties;

/**
* Handler invoked when the cancel button is clicked.
* Indicates how long (in milliseconds) the overlay's enter/leave transition takes.
* This is used by React `CSSTransition` to know when a transition completes and must match
* the duration of the animation in CSS. Only set this prop if you override Blueprint's default
* transitions with new transitions of a different length.
* @default 300
*/
transitionDuration?: number;

/**
* Handler invoked when the alert is canceled. Alerts can be **canceled** in the following ways:
* - clicking the cancel button (if `cancelButtonText` is defined)
* - pressing the escape key (if `canEscapeKeyCancel` is enabled)
* - clicking on the overlay backdrop (if `canOutsideClickCancel` is enabled)
*
* If any of the `cancel` props are defined, then either `onCancel` or `onClose` must be defined.
*/
onCancel?(e: React.MouseEvent<HTMLButtonElement>): void;
onCancel?(evt?: React.SyntheticEvent<HTMLElement>): void;

/**
* Handler invoked when the confirm button is clicked.
* Handler invoked when the confirm button is clicked. Alerts can be **confirmed** in the following ways:
* - clicking the confirm button
* - focusing on the confirm button and pressing `enter` or `space`
*/
onConfirm(e: React.MouseEvent<HTMLButtonElement>): void;
onConfirm?(evt?: React.SyntheticEvent<HTMLElement>): void;

/**
* Handler invoked when the Alert is confirmed or canceled; see `onConfirm` and `onCancel` for more details.
* First argument is `true` if confirmed, `false` otherwise.
* This is an alternative to defining separate `onConfirm` and `onCancel` handlers.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why support 3 handlers when one will do? either remove this (leave the callbacks as-is) or remove the existing ones and only support onClose

Copy link
Contributor Author

@giladgray giladgray Feb 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TagInput also has three callbacks, as does EditableText

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alright fine 🤷‍♂️

*/
onClose?(confirmed: boolean, evt?: React.SyntheticEvent<HTMLElement>): void;
}

export class Alert extends AbstractPureComponent<IAlertProps, {}> {
public static defaultProps: IAlertProps = {
canEscapeKeyCancel: false,
canOutsideClickCancel: false,
confirmButtonText: "OK",
isOpen: false,
onConfirm: null,
};

public static displayName = "Blueprint2.Alert";

public render() {
const { children, className, icon, intent, isOpen, confirmButtonText, onConfirm, style } = this.props;
const { children, className, icon, intent, cancelButtonText, confirmButtonText } = this.props;
return (
<Dialog className={classNames(Classes.ALERT, className)} isOpen={isOpen} style={style}>
<Dialog
className={classNames(Classes.ALERT, className)}
canEscapeKeyClose={this.props.canEscapeKeyCancel}
canOutsideClickClose={this.props.canOutsideClickCancel}
isOpen={this.props.isOpen}
onClose={this.handleCancel}
style={this.props.style}
transitionDuration={this.props.transitionDuration}
>
<div className={Classes.ALERT_BODY}>
<Icon icon={icon} iconSize={40} intent={intent} />
<div className={Classes.ALERT_CONTENTS}>{children}</div>
</div>
<div className={Classes.ALERT_FOOTER}>
<Button intent={intent} text={confirmButtonText} onClick={onConfirm} />
{this.maybeRenderSecondaryAction()}
<Button intent={intent} text={confirmButtonText} onClick={this.handleConfirm} />
{cancelButtonText && <Button text={cancelButtonText} onClick={this.handleCancel} />}
</div>
</Dialog>
);
}

protected validateProps(props: IAlertProps) {
if (
(props.cancelButtonText != null && props.onCancel == null) ||
(props.cancelButtonText == null && props.onCancel != null)
) {
if (props.onClose == null && (props.cancelButtonText == null) !== (props.onCancel == null)) {
console.warn(ALERT_WARN_CANCEL_PROPS);
}
}

private maybeRenderSecondaryAction() {
if (this.props.cancelButtonText != null) {
return <Button text={this.props.cancelButtonText} onClick={this.props.onCancel} />;
const hasCancelHandler = props.onCancel != null || props.onClose != null;
if (props.canEscapeKeyCancel && !hasCancelHandler) {
console.warn(ALERT_WARN_CANCEL_ESCAPE_KEY);
}
return undefined;
if (props.canOutsideClickCancel && !hasCancelHandler) {
console.warn(ALERT_WARN_CANCEL_OUTSIDE_CLICK);
}
}

private handleCancel = (evt: React.SyntheticEvent<HTMLElement>) => this.internalHandleCallbacks(false, evt);
private handleConfirm = (evt: React.SyntheticEvent<HTMLElement>) => this.internalHandleCallbacks(true, evt);

private internalHandleCallbacks(confirmed: boolean, evt?: React.SyntheticEvent<HTMLElement>) {
const { onCancel, onClose, onConfirm } = this.props;
safeInvoke(confirmed ? onConfirm : onCancel, evt);
safeInvoke(onClose, confirmed, evt);
}
}
117 changes: 99 additions & 18 deletions packages/core/test/alert/alertTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,23 @@
*/

import { assert } from "chai";
import { shallow, ShallowWrapper } from "enzyme";
import { mount, shallow, ShallowWrapper } from "enzyme";
import * as React from "react";
import { SinonSpy, spy } from "sinon";
import { SinonStub, spy, stub } from "sinon";

import { Alert, Button, Classes, Icon, Intent } from "../../src/index";

const NOOP: () => any = () => undefined;
import * as Errors from "../../src/common/errors";
import { Alert, Button, Classes, IAlertProps, IButtonProps, Icon, Intent, Keys } from "../../src/index";

describe("<Alert>", () => {
it("renders its content correctly", () => {
const noop = () => true;
const wrapper = shallow(
<Alert
className="test-class"
isOpen={true}
confirmButtonText="Delete"
cancelButtonText="Cancel"
onConfirm={NOOP}
onCancel={NOOP}
onClose={noop}
>
<p>Are you sure you want to delete this file?</p>
<p>There is no going back.</p>
Expand All @@ -37,7 +36,7 @@ describe("<Alert>", () => {

it("renders the icon correctly", () => {
const wrapper = shallow(
<Alert icon="warning-sign" isOpen={true} confirmButtonText="Delete" onConfirm={NOOP}>
<Alert icon="warning-sign" isOpen={true} confirmButtonText="Delete">
<p>Are you sure you want to delete this file?</p>
<p>There is no going back.</p>
</Alert>,
Expand All @@ -47,25 +46,30 @@ describe("<Alert>", () => {
});

describe("confirm button", () => {
let wrapper: ShallowWrapper<any, any>;
let onConfirm: SinonSpy;
const onConfirm = spy();
const onClose = spy();
let wrapper: ShallowWrapper<IAlertProps, any>;

beforeEach(() => {
onConfirm = spy();
onConfirm.resetHistory();
onClose.resetHistory();
wrapper = shallow(
<Alert
icon="warning-sign"
intent={Intent.PRIMARY}
isOpen={true}
confirmButtonText="Delete"
onConfirm={onConfirm}
onClose={onClose}
>
<p>Are you sure you want to delete this file?</p>
<p>There is no going back.</p>
</Alert>,
);
});

afterEach(() => wrapper.unmount());

it("text is confirmButtonText", () => {
assert.equal(wrapper.find(Button).prop("text"), "Delete");
});
Expand All @@ -74,19 +78,23 @@ describe("<Alert>", () => {
assert.equal(wrapper.find(Button).prop("intent"), Intent.PRIMARY);
});

it("onClick triggered on click", () => {
it("onConfirm and onClose triggered on click", () => {
wrapper.find(Button).simulate("click");
assert.isTrue(onConfirm.calledOnce);
assert.isTrue(onClose.calledOnce);
assert.strictEqual(onClose.args[0][0], true);
});
});

describe("cancel button", () => {
let wrapper: ShallowWrapper<any, any>;
let onCancel: SinonSpy;
let cancelButton: ShallowWrapper<any, any>;
const onCancel = spy();
const onClose = spy();
let wrapper: ShallowWrapper<IAlertProps, any>;
let cancelButton: ShallowWrapper<IButtonProps, any>;

beforeEach(() => {
onCancel = spy();
onCancel.resetHistory();
onClose.resetHistory();
wrapper = shallow(
<Alert
icon="warning-sign"
Expand All @@ -95,7 +103,7 @@ describe("<Alert>", () => {
cancelButtonText="Cancel"
confirmButtonText="Delete"
onCancel={onCancel}
onConfirm={NOOP}
onClose={onClose}
>
<p>Are you sure you want to delete this file?</p>
<p>There is no going back.</p>
Expand All @@ -104,6 +112,8 @@ describe("<Alert>", () => {
cancelButton = wrapper.find(Button).last();
});

afterEach(() => wrapper.unmount());

it("text is cancelButtonText", () => {
assert.equal(cancelButton.prop("text"), "Cancel");
});
Expand All @@ -112,9 +122,80 @@ describe("<Alert>", () => {
assert.isUndefined(cancelButton.prop("intent"));
});

it("onClick triggered on click", () => {
it("onCancel and onClose triggered on click", () => {
cancelButton.simulate("click");
assert.isTrue(onCancel.calledOnce);
assert.isTrue(onClose.calledOnce);
assert.strictEqual(onClose.args[0][0], false);
});

it("canEscapeKeyCancel enables escape key", () => {
const alert = mount<IAlertProps>(
<Alert isOpen={true} cancelButtonText="Cancel" confirmButtonText="Delete" onCancel={onCancel}>
<p>Are you sure you want to delete this file?</p>
<p>There is no going back.</p>
</Alert>,
);
const overlay = alert.find(".pt-overlay").hostNodes();

overlay.simulate("keydown", { which: Keys.ESCAPE });
assert.isTrue(onCancel.notCalled);

alert.setProps({ canEscapeKeyCancel: true });
overlay.simulate("keydown", { which: Keys.ESCAPE });
assert.isTrue(onCancel.calledOnce);

alert.unmount();
});

it("canOutsideClickCancel enables outside click", () => {
const alert = mount<IAlertProps>(
<Alert isOpen={true} cancelButtonText="Cancel" confirmButtonText="Delete" onCancel={onCancel}>
<p>Are you sure you want to delete this file?</p>
<p>There is no going back.</p>
</Alert>,
);
const backdrop = alert.find(".pt-overlay-backdrop").hostNodes();

backdrop.simulate("mousedown");
assert.isTrue(onCancel.notCalled);

alert.setProps({ canOutsideClickCancel: true });
backdrop.simulate("mousedown");
assert.isTrue(onCancel.calledOnce);

alert.unmount();
});
});

describe("warnings", () => {
let warnSpy: SinonStub;
before(() => (warnSpy = stub(console, "warn")));
afterEach(() => warnSpy.resetHistory());
after(() => warnSpy.restore());

it("cancelButtonText without cancel handler", () => {
testWarn(<Alert cancelButtonText="cancel" isOpen={false} />, Errors.ALERT_WARN_CANCEL_PROPS);
});

it("canEscapeKeyCancel without cancel handler", () => {
testWarn(<Alert canEscapeKeyCancel={true} isOpen={false} />, Errors.ALERT_WARN_CANCEL_ESCAPE_KEY);
});

it("canOutsideClickCancel without cancel handler", () => {
testWarn(<Alert canOutsideClickCancel={true} isOpen={false} />, Errors.ALERT_WARN_CANCEL_OUTSIDE_CLICK);
});

function testWarn(alert: JSX.Element, warning: string) {
// one warning
const wrapper = shallow(alert);
assert.strictEqual(warnSpy.callCount, 1);
assert.isTrue(warnSpy.calledWithExactly(warning));
// no more warnings
wrapper
.setProps({ onClose: () => true })
.setProps({ cancelButtonText: "cancel", onCancel: () => true, onClose: undefined });
assert.strictEqual(warnSpy.callCount, 1);
}
});
});
Loading