diff --git a/out8492 b/out8492 new file mode 160000 index 00000000000..30cace674aa --- /dev/null +++ b/out8492 @@ -0,0 +1 @@ +Subproject commit 30cace674aa93af4e30717927f698f88bce80f41 diff --git a/packages/patternfly-react/package.json b/packages/patternfly-react/package.json index 7d3ac7bc5b0..b559807b2f6 100644 --- a/packages/patternfly-react/package.json +++ b/packages/patternfly-react/package.json @@ -34,7 +34,9 @@ "react-bootstrap-typeahead": "^3.1.3", "react-c3js": "^0.1.20", "react-click-outside": "^3.0.1", + "react-collapse": "^4.0.3", "react-fontawesome": "^1.6.1", + "react-motion": "^0.5.2", "reactabular-table": "^8.14.0", "recompose": "^0.26.0" }, diff --git a/packages/patternfly-react/src/components/LoginPage/LoginPage.js b/packages/patternfly-react/src/components/LoginPage/LoginPage.js new file mode 100644 index 00000000000..b4699105f3b --- /dev/null +++ b/packages/patternfly-react/src/components/LoginPage/LoginPage.js @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Container from './components/LoginPageComponents/LoginPageContainer'; +import Header from './components/LoginPageComponents/LoginPageHeader'; +import Footer from './components/LoginPageComponents/LoginPageFooter'; +import FooterLinks from './components/LoginPageComponents/LoginFooterLinks'; +import LoginCard from './components/LoginCardComponents/LoginCard'; +import WithTranslation from './components/LoginPageComponents/LoginPageWithTranslation'; +import LoginPageAlert from './components/LoginPageComponents/LoginPageAlert'; +import LoginPageLink from './components/LoginPageComponents/LoginPageLink'; +import SocialLoginPage from './SocialLoginPage'; +import SocialLoginPageContainer from './components/LoginPageComponents/SocialLoginPageContainer'; +import BasicLoginPageLayout from './components/LoginPageComponents/BasicLoginPageLayout'; + +const LoginPagePattern = ({ container, header, footerLinks, card }) => ( + + + + + + + + +

{card.title}

+
+ + + + +
+ +
+
+
+); + +const LoginPage = props => ( + + + +); + +LoginPage.Container = Container; +LoginPage.Header = Header; +LoginPage.Footer = Footer; +LoginPage.Card = LoginCard; +LoginPage.FooterLinks = FooterLinks; +LoginPage.WithTranslation = WithTranslation; +LoginPage.Alert = LoginPageAlert; +LoginPage.Pattern = LoginPagePattern; +LoginPage.Social = SocialLoginPage; +LoginPage.SocialContainer = SocialLoginPageContainer; +LoginPage.BasicLayout = BasicLoginPageLayout; +LoginPage.Link = LoginPageLink; + +LoginPagePattern.propTypes = { + container: PropTypes.shape({ ...LoginPage.Container.propTypes }), + header: PropTypes.shape({ ...LoginPage.Header.propTypes }), + card: PropTypes.shape({ + ...LoginCard.LanguagePicker.propTypes, + ...LoginCard.Form.propTypes, + ...LoginCard.SignUp.propTypes, + ...LoginCard.RememberMe.propTypes, + ...LoginCard.ForgotPassword.propTypes + }), + footerLinks: PropTypes.array +}; + +LoginPagePattern.defaultProps = { + container: { ...LoginPage.Container.defaultProps }, + header: { ...LoginPage.Header.defaultProps }, + card: { + ...LoginCard.LanguagePicker.defaultProps, + ...LoginCard.Form.defaultProps, + ...LoginCard.SignUp.defaultProps, + ...LoginCard.RememberMe.defaultProps, + ...LoginCard.ForgotPassword.defaultProps + }, + footerLinks: [...LoginPage.Footer.defaultProps.links] +}; + +LoginPage.propTypes = { ...LoginPagePattern.propTypes }; +LoginPage.defaultProps = { ...LoginPagePattern.defaultProps }; + +export default LoginPage; diff --git a/packages/patternfly-react/src/components/LoginPage/LoginPage.stories.js b/packages/patternfly-react/src/components/LoginPage/LoginPage.stories.js new file mode 100644 index 00000000000..35c615946dd --- /dev/null +++ b/packages/patternfly-react/src/components/LoginPage/LoginPage.stories.js @@ -0,0 +1,238 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { withKnobs, number } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; +import { defaultTemplate } from 'storybook/decorators/storyTemplates'; +import { + storybookPackageName, + STORYBOOK_CATEGORY, + DOCUMENTATION_URL +} from 'storybook/constants/siteConstants'; +import LoginPage from './LoginPage'; +import englishMessages from './mocks/messages.en'; +import frenchMessages from './mocks/messages.fr'; +import images from './assets/img'; +import { name } from '../../../package.json'; + +const stories = storiesOf( + `${storybookPackageName(name)}/${ + STORYBOOK_CATEGORY.APPLICATION_FRAMEWORK + }/Login Page`, + module +); + +stories.addDecorator(withKnobs); +stories.addDecorator( + defaultTemplate({ + title: 'Login Page', + documentationLink: `${ + DOCUMENTATION_URL.PATTERNFLY_ORG_APP_FRAMEWORK + }login-page` + }) +); + +const storyAction = (e, message) => { + e.preventDefault(); + action(message)(); +}; + +const createProps = () => { + const { header, footerLinks, card } = englishMessages; + footerLinks.forEach(link => { + link.onClick = e => storyAction(e, 'Footer Link was pressed'); + }); + return { + container: { + backgroundUrl: images.background, + translations: { en: englishMessages, fr: frenchMessages }, + className: '', + alert: { + message: header.alert, + onDismiss: e => storyAction(e, 'Notification was dismissed'), + show: true + } + }, + header: { + logoSrc: images.brand, + logoTitle: header.logo, + caption: header.caption + }, + footerLinks, + card: { + title: card.header.title, + selectedLanguage: card.header.selectedLanguage, + availableLanguages: card.header.availableLanguages, + signUp: { + label: card.signUp.label, + link: { + children: card.signUp.link.label, + href: '#', + onClick: e => storyAction(e, 'sign up was clicked') + } + }, + form: { + validate: true, + submitError: card.form.error, + showError: true, + usernameField: { + id: 'card_email', + type: 'email', + placeholder: card.usernameField.placeholder, + errors: card.usernameField.errors, + error: card.usernameField.errors.invalid, + showError: true + }, + passwordField: { + id: 'card_password', + type: 'password', + placeholder: card.passwordField.placeholder, + minLength: 8, + errors: card.passwordField.errors, + warnings: card.passwordField.warnings, + warning: card.passwordField.warnings.capsLock, + showWarning: true + }, + additionalFields: null, + rememberMe: { + label: card.rememberMe, + onClick: e => action('remember me checkbox was clicked')() + }, + forgotPassword: { + label: card.forgotPassword, + href: '#', + onClick: e => storyAction(e, 'Forgot password was clicked') + }, + disableSubmit: false, + submitText: card.form.submitText, + onSubmit: e => storyAction(e, 'Form was submitted') + }, + social: { + links: createLogoList() + } + } + }; +}; + +const createLogoList = () => { + const socialLinkClick = e => storyAction(e, 'Social Link was clicked'); + const { + google, + facebook, + linkedin, + github, + instagram, + stackExchange, + twitter, + git, + openID, + dropbox, + fedora, + skype + } = images; + return [ + { + src: google, + alt: 'Google', + text: 'Google', + onClick: e => socialLinkClick(e) + }, + { + src: facebook, + alt: 'Facebook', + text: 'Facebook', + onClick: e => socialLinkClick(e) + }, + { + src: linkedin, + alt: 'Linkedin', + text: 'Linkedin', + onClick: e => socialLinkClick(e) + }, + { + src: github, + alt: 'Github', + text: 'Github', + onClick: e => socialLinkClick(e) + }, + { + src: instagram, + alt: 'Instagram', + text: 'Instagram', + onClick: e => socialLinkClick(e) + }, + { + src: git, + alt: 'Git', + text: 'Git', + onClick: e => socialLinkClick(e) + }, + { + src: openID, + alt: 'OpenID', + text: 'OpenID', + onClick: e => socialLinkClick(e) + }, + { + src: dropbox, + alt: 'Dropbox', + text: 'Dropbox', + onClick: e => socialLinkClick(e) + }, + { + src: fedora, + alt: 'Fedora', + text: 'Fedora', + onClick: e => socialLinkClick(e) + }, + { + src: skype, + alt: 'Skype', + text: 'Skype', + onClick: e => socialLinkClick(e) + }, + { + src: twitter, + alt: 'Twitter', + text: 'Twitter', + onClick: e => socialLinkClick(e) + }, + { + src: stackExchange, + alt: 'StackExchange', + text: 'StackExchange', + onClick: e => socialLinkClick(e) + } + ]; +}; + +stories.addWithInfo('Managed Basic Login Page', () => ( + +)); + +stories.addWithInfo('Build Your own Basic Login Page', () => { + const props = { ...createProps() }; + props.card.form.validate = false; + return LoginPage.Pattern(props); +}); + +stories.addWithInfo('Managed Social Login Page', () => { + const logoListCopy = createLogoList(); + const listSize = number('Social List Size', 12); + const socialLinks = logoListCopy.splice(0, listSize); + + const props = { ...createProps() }; + props.card.social.links = socialLinks; + + return ; +}); + +stories.addWithInfo('Build Your own Social Login Page', () => { + const logoListCopy = createLogoList(); + const listSize = number('Social List Size', 12); + const socialLinks = logoListCopy.splice(0, listSize); + + const props = { ...createProps() }; + props.card.social.links = socialLinks; + props.card.form.validate = false; + return LoginPage.Social.Pattern(props); +}); diff --git a/packages/patternfly-react/src/components/LoginPage/LoginPage.test.js b/packages/patternfly-react/src/components/LoginPage/LoginPage.test.js new file mode 100644 index 00000000000..c9c04388c26 --- /dev/null +++ b/packages/patternfly-react/src/components/LoginPage/LoginPage.test.js @@ -0,0 +1,366 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { DropdownButton } from 'react-bootstrap'; +import englishMessages from './mocks/messages.en'; +import frenchMessages from './mocks/messages.fr'; +import { LoginPage, LoginCardWithValidation } from './index'; + +const { Input, ForgotPassword, SignUp } = LoginPage.Card; +const { FooterLinks } = LoginPage; + +const mockFunction = jest.fn(); +const createProps = () => { + const { header, card } = englishMessages; + return { + container: { + translations: { en: englishMessages, fr: frenchMessages }, + alert: { + message: header.alert, + show: true + } + }, + header: { + logoTitle: header.logo, + caption: header.caption + }, + footerLinks: [ + { children: 'Terms of Use', href: '#', onClick: mockFunction }, + { children: 'Help', href: '#', onClick: mockFunction }, + { children: 'Privacy Policy', href: '#', onClick: mockFunction } + ], + card: { + title: card.header.title, + selectedLanguage: card.header.selectedLanguage, + availableLanguages: card.header.availableLanguages, + signUp: { + label: card.signUp.label, + link: { + label: card.signUp.link.label, + href: '#', + onClick: mockFunction + } + }, + form: { + error: card.form.error, + showError: true, + usernameField: { + id: 'card_email', + type: 'email', + placeholder: card.usernameField.placeholder, + errors: card.usernameField.errors, + error: card.usernameField.errors.invalid, + showError: true + }, + passwordField: { + id: 'card_password', + type: 'password', + placeholder: card.passwordField.placeholder, + errors: card.passwordField.errors, + minLength: 8, + warnings: card.passwordField.warnings, + warning: card.passwordField.warnings.capsLock, + showWarning: true + }, + rememberMe: { + label: card.rememberMe, + onChange: mockFunction + }, + forgotPassword: { + label: card.forgotPassword, + href: '#', + onClick: mockFunction + }, + disableSubmit: false, + submitText: card.form.submitText + } + } + }; +}; + +afterEach(() => mockFunction.mockClear()); + +test('Component matches snapshot', () => { + const component = mount(); + expect(component.render()).toMatchSnapshot(); +}); + +test('Alert closes succesfully', () => { + const component = mount(); + component + .find('div.alert button') + .at(0) + .simulate('click'); + + expect( + component + .find('div.alert button') + .at(0) + .exists() + ).toEqual(false); +}); + +test('Dropdown updates succesfully', () => { + const component = mount(); + component + .find('ul.dropdown-menu li a') + .at(1) + .simulate('click'); + + expect( + component + .find(DropdownButton) + .at(0) + .props().title + ).toEqual(frenchMessages.card.header.selectedLanguage.text); +}); + +test('Toggle Caps lock warning in password field by the events: focus, blur and mouseEnter', () => { + const component = mount(); + const passwordElement = component.find('input[type="password"]').at(0); + + passwordElement + .simulate('focus') + .simulate('keypress', { keyCode: 80, shiftKey: false }); + + expect( + component + .find(Input) + .at(1) + .props().warning + ).toEqual(englishMessages.card.passwordField.warnings.capsLock); + + expect( + component + .find(Input) + .at(1) + .props().showWarning + ).toEqual(true); + + passwordElement + .simulate('blur') + .simulate('keypress', { keyCode: 80, shiftKey: false }); + + expect( + component + .find(Input) + .at(1) + .props().showWarning + ).toEqual(false); + + passwordElement + .simulate('keypress', { keyCode: 80, shiftKey: false }) + .simulate('focus'); + + expect( + component + .find(Input) + .at(1) + .props().showWarning + ).toEqual(true); +}); + +test('Toggle CapsLock cause warning to show under password field when focused', () => { + const component = mount(); + component + .find('input[type="password"]') + .simulate('change', { target: { value: 'test' } }); + component + .find(LoginCardWithValidation) + .instance() + .toggleCapsLock({ key: 'CapsLock' }); + + component.find('input[type="password"]').simulate('focus'); + + expect( + component + .find(Input) + .at(1) + .props().showWarning + ).toBeTruthy(); +}); + +test('Submit while inputs are empty cause specific errors to be shown and onChange they disappear', () => { + const component = mount(); + const usernameElement = component.find('input[type="email"]').at(0); + const passwordElement = component.find('input[type="password"]').at(0); + component + .find('form') + .at(0) + .simulate('submit'); + + // check username field + expect( + component + .find(Input) + .at(0) + .props().error + ).toEqual(englishMessages.card.usernameField.errors.empty); + + expect( + component + .find(Input) + .at(0) + .props().showError + ).toEqual(true); + + usernameElement.simulate('change', { target: { value: 'Ron' } }); + + expect( + component + .find(Input) + .at(0) + .props().showError + ).toEqual(false); + + // check password field + expect( + component + .find(Input) + .at(1) + .props().error + ).toEqual(englishMessages.card.passwordField.errors.empty); + + expect( + component + .find(Input) + .at(1) + .props().showError + ).toEqual(true); + + passwordElement.simulate('change', { target: { value: 'Q!w2e3' } }); + + expect( + component + .find(Input) + .at(1) + .props().showError + ).toEqual(false); +}); + +test('Submit while password is too short raises the correct error', () => { + const component = mount(); + const passwordElement = component.find('input[type="password"]').at(0); + + passwordElement.simulate('change', { target: { value: 'short' } }); + + component + .find('form') + .at(0) + .simulate('submit'); + + expect( + component + .find(Input) + .at(1) + .props().showError + ).toEqual(true); + + expect( + component + .find(Input) + .at(1) + .props().error + ).toEqual(englishMessages.card.passwordField.errors.short); +}); + +test('Submit while username is invalid cause a specific error to be shown and onChange is disappears', () => { + const component = mount(); + const usernameElement = component.find('input[type="email"]').at(0); + usernameElement.simulate('change', { target: { value: 'agagagagag@' } }); + + component + .find('form') + .at(0) + .simulate('submit'); + + // check username field + expect( + component + .find(Input) + .at(0) + .props().error + ).toEqual(englishMessages.card.usernameField.errors.invalid); + + expect( + component + .find(Input) + .at(0) + .props().showError + ).toEqual(true); + + usernameElement.simulate('change', { target: { value: 'ron@redhat.com' } }); + + expect( + component + .find(Input) + .at(0) + .props().showError + ).toEqual(false); +}); + +test('Remember-me checkbox change trigger mock function', () => { + const component = mount(); + component + .find('div.login-pf-settings input[type="checkbox"]') + .at(0) + .simulate('change'); + + expect(mockFunction).toHaveBeenCalled(); +}); + +test('Forgot Password link click trigger mock function', () => { + const component = mount(); + component + .find(ForgotPassword) + .find('a') + .at(0) + .simulate('click'); + + expect(mockFunction).toHaveBeenCalled(); +}); + +test('Sign-up click trigger mock function', () => { + const component = mount(); + component + .find(SignUp) + .find('a') + .at(0) + .simulate('click'); + + expect(mockFunction).toHaveBeenCalled(); +}); + +test('Footer-links click trigger mock function', () => { + const component = mount(); + component + .find(FooterLinks) + .find('ul li a') + .at(0) + .simulate('click'); + + expect(mockFunction).toHaveBeenCalled(); +}); + +test('Translation works', () => { + const component = mount(); + + // Click on french + component + .find('ul.dropdown-menu li a') + .at(1) + .simulate('click'); + + expect( + component + .find(DropdownButton) + .at(0) + .props().title + ).toEqual(frenchMessages.card.header.selectedLanguage.text); + + expect( + component + .find(LoginPage.Pattern) + .at(0) + .props().header.caption + ).toEqual(frenchMessages.header.caption); +}); diff --git a/packages/patternfly-react/src/components/LoginPage/SocialLoginPage.js b/packages/patternfly-react/src/components/LoginPage/SocialLoginPage.js new file mode 100644 index 00000000000..669b1f632e0 --- /dev/null +++ b/packages/patternfly-react/src/components/LoginPage/SocialLoginPage.js @@ -0,0 +1,50 @@ +import React from 'react'; +import LoginCard from './components/LoginCardComponents/LoginCard'; +import LoginPage from './LoginPage'; + +const SocialLoginPagePattern = ({ container, header, footerLinks, card }) => ( + + + + + + +

{card.title}

+
+ + + + + + + + + +
+ +
+); + +const SocialLoginPage = props => ( + + + +); + +const getLoginPage = props => LoginPage(props); + +SocialLoginPage.Pattern = SocialLoginPagePattern; + +SocialLoginPagePattern.propTypes = { + ...getLoginPage.propTypes +}; + +SocialLoginPagePattern.defaultProps = { + ...getLoginPage.defaultProps +}; + +export default SocialLoginPage; diff --git a/packages/patternfly-react/src/components/LoginPage/SocialLoginPage.test.js b/packages/patternfly-react/src/components/LoginPage/SocialLoginPage.test.js new file mode 100644 index 00000000000..782eaf640c1 --- /dev/null +++ b/packages/patternfly-react/src/components/LoginPage/SocialLoginPage.test.js @@ -0,0 +1,180 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import englishMessages from './mocks/messages.en'; +import frenchMessages from './mocks/messages.fr'; +import { SocialLoginPage } from './index'; + +const mockFunction = jest.fn(); +const logoList = [ + { + alt: 'Google', + text: 'Google', + onClick: mockFunction + }, + { + alt: 'Facebook', + text: 'Facebook', + onClick: mockFunction + }, + { + alt: 'Linkedin', + text: 'Linkedin', + onClick: mockFunction + }, + { + alt: 'Github', + text: 'Github', + onClick: mockFunction + }, + { + alt: 'Instagram', + text: 'Instagram', + onClick: mockFunction + }, + { + alt: 'Git', + text: 'Git', + onClick: mockFunction + }, + { + alt: 'OpenID', + text: 'OpenID', + onClick: mockFunction + }, + { + alt: 'Dropbox', + text: 'Dropbox', + onClick: mockFunction + }, + { + alt: 'Fedora', + text: 'Fedora', + onClick: mockFunction + }, + { + alt: 'Skype', + text: 'Skype', + onClick: mockFunction + }, + { + alt: 'Twitter', + text: 'Twitter', + onClick: mockFunction + }, + { + alt: 'StackExchange', + text: 'StackExchange', + onClick: mockFunction + } +]; +const createProps = () => { + const { header, footerLinks, card } = englishMessages; + return { + container: { + translations: { en: englishMessages, fr: frenchMessages }, + className: '', + alert: { + message: header.alert, + onDismiss: mockFunction, + show: true + } + }, + header: { + logoTitle: header.logo, + caption: header.caption + }, + footerLinks, + card: { + title: card.header.title, + selectedLanguage: card.header.selectedLanguage, + availableLanguages: card.header.availableLanguages, + signUp: { + label: card.signUp.label, + link: { + children: card.signUp.link.label, + href: '#', + onClick: mockFunction + } + }, + form: { + validate: true, + submitError: card.form.error, + showError: true, + usernameField: { + id: 'card_email', + type: 'email', + placeholder: card.usernameField.placeholder, + errors: card.usernameField.errors, + error: card.usernameField.errors.invalid, + showError: true + }, + passwordField: { + id: 'card_password', + type: 'password', + placeholder: card.passwordField.placeholder, + minLength: 8, + errors: card.passwordField.errors, + warnings: card.passwordField.warnings, + warning: card.passwordField.warnings.capsLock, + showWarning: true + }, + rememberMe: { + label: card.rememberMe, + onClick: mockFunction + }, + forgotPassword: { + label: card.forgotPassword, + href: '#', + onClick: mockFunction + }, + disableSubmit: false, + submitText: card.form.submitText, + onSubmit: mockFunction + }, + social: { + links: logoList + } + } + }; +}; + +afterEach(() => mockFunction.mockClear()); + +test('Component matches snapshot', () => { + const props = { ...createProps() }; + props.card.social.links = []; + const component = mount(); + expect(component.render()).toMatchSnapshot(); +}); + +test('Click on social link triggers mock function', () => { + const component = mount(); + component + .find('.login-pf-social-link') + .at(0) + .find('a') + .simulate('click'); + + expect(mockFunction).toHaveBeenCalled(); +}); + +test('Click on the "More" button will expend the list and the button will change to "Less"', () => { + const component = mount(); + const toggleBtn = component.find('.login-pf-social-toggle'); + + toggleBtn.simulate('click'); + + expect(toggleBtn.text()).toEqual(expect.stringMatching('Less')); + + expect(component.find('.ReactCollapse--collapse > ul > li')).toBeTruthy(); +}); + +test("While the social list has 4 or less links, it won't have the 'double-col' class and the expend button won't exist", () => { + const props = { ...createProps() }; + props.card.social.links = [...logoList].splice(0, 4); + const component = mount(); + const socialList = component.find('ul.login-pf-social'); + expect(socialList.hasClass('login-pf-social-double-col')).toBeFalsy(); + const toggleBtn = component.find('.login-pf-social-toggle'); + expect(toggleBtn.exists()).toBeFalsy(); +}); diff --git a/packages/patternfly-react/src/components/LoginPage/__snapshots__/LoginPage.test.js.snap b/packages/patternfly-react/src/components/LoginPage/__snapshots__/LoginPage.test.js.snap new file mode 100644 index 00000000000..ec8831364c8 --- /dev/null +++ b/packages/patternfly-react/src/components/LoginPage/__snapshots__/LoginPage.test.js.snap @@ -0,0 +1,233 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Component matches snapshot 1`] = ` +