Skip to content

Commit

Permalink
feat: Add LoginForm and initial versions of dependent components
Browse files Browse the repository at this point in the history
  • Loading branch information
wms committed Sep 14, 2017
1 parent da7cadf commit 8a57fd1
Show file tree
Hide file tree
Showing 18 changed files with 824 additions and 11 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
**/*.js.snap
**/*.d.ts
node_modules
coverage
dist
.awcache
4 changes: 3 additions & 1 deletion .npmignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
!**/*.d.ts
**/*.spec.*
**/__mocks__/**
**/__snapshots__/**
**/__snapshots__/**
stories
coverage
86 changes: 86 additions & 0 deletions CredentialStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import moment from 'moment';

import { CredentialStore } from './CredentialStore';

const localStorage = {
setItem: jest.fn(),
getItem: jest.fn(),
removeItem: jest.fn()
}

Object.assign(global, { localStorage });

let store: CredentialStore;

beforeEach(() => {
store = new CredentialStore('TEST_TOKEN');
});

afterEach(() => jest.resetAllMocks());

const makeToken = (payload: {}): string => {
const header = btoa(JSON.stringify({
alg: 'hs256',
typ: 'JWT'
}));

const _payload = btoa(JSON.stringify(payload));

return `${header}.${_payload}`;
}

describe('setToken()', () => {
it('sets stored token', () => {
store.setToken('test');

expect(localStorage.setItem).toHaveBeenCalledWith('TEST_TOKEN', 'test');
});
});

describe('getToken()', () => {
describe('when token is missing', () => {
it('returns `undefined`', () => {
expect(store.getToken()).toBeUndefined();
});
});

describe('when token has no expiry', () => {
const token = makeToken({ id: 500 });

it('returns the token', () => {
localStorage.getItem.mockImplementation(() => token);
expect(store.getToken()).toBe(token);
});
});

describe('when the token has an expiry in the future', () => {
const token = makeToken({
id: 500,
exp: moment().unix() + 1000
});

it('returns the token', () => {
localStorage.getItem.mockImplementation(() => token);
expect(store.getToken()).toBe(token);
});
});

describe('when the token has an expiry in the past', () => {
const token = makeToken({
id: 500,
exp: 1000
});

it('returns `undefined`', () => {
localStorage.getItem.mockImplementation(() => token);
expect(store.getToken()).toBeUndefined();
});
});
});

describe('clearToken()', () => {
it('clears stored token', () => {
store.clearToken();
expect(localStorage.removeItem).toHaveBeenCalledWith('TEST_TOKEN');
});
})
31 changes: 31 additions & 0 deletions CredentialStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import moment from 'moment';

export class CredentialStore {
constructor(protected _key: string) { }

setToken(token: string) {
localStorage.setItem(this._key, token);
}

getToken(): string | undefined {
const token = localStorage.getItem(this._key);

if (!token) {
return;
}

const [, payload] = token.split('.');

const { exp } = JSON.parse(atob(payload));

if (exp && exp < moment().unix()) {
return;
}

return token;
}

clearToken() {
localStorage.removeItem(this._key);
}
}
38 changes: 38 additions & 0 deletions components/Form.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Button } from 'antd';

import { Form } from './Form';

const mockForm = {
properties: {},
submit: jest.fn().mockReturnValue(Promise.resolve())
} as any;

describe('default behaviour', () => {
it('renders an empty Ant form', () => {
const form = (
<Form
resourceForm={mockForm}
/>
);

expect(form).toMatchSnapshot();
});

it('triggers form submission', async () => {
const form = shallow(
<Form
resourceForm={mockForm}
/>
);

expect(form.state().submitting).toBe(false);

form
.find(Button)
.first()
.simulate('click');
});
});

128 changes: 128 additions & 0 deletions components/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Resource, Form as HALForm } from '@optics/hal-client';
import { Data, Schema } from '@optics/hal-client/dist/Form';
import { Form as AntForm, Button } from 'antd';
import { FormProps as AntFormProps, WrappedFormUtils } from 'antd/lib/form/Form';
import { FormItemProps as AntFormItemProps } from 'antd/lib/form/FormItem';

const DEFAULT_SUBMIT_BUTTON = (
<Button type="primary">
Submit
</Button>
);

export const childContextTypes = {
schema: PropTypes.object,
defaultItemProps: PropTypes.object
}

export interface Props {
resourceForm: HALForm;
submitButton?: React.ReactElement<any> | false;
onSuccess?: (result: Resource) => void;
onFailure?: (error: Error) => void;
defaultItemProps?: Partial<AntFormItemProps>;
}

export interface State {
submitting: boolean;
}

export interface Context {
schema: Schema;
defaultItemProps: Partial<AntFormItemProps>;
}

class FormComponent extends React.Component<Props & AntFormProps> {
state: State = {
submitting: false
};

static childContextTypes = childContextTypes;

render() {
const {
resourceForm,
submitButton,
onSuccess,
onFailure,
children,
form,
...formProps
} = this.props;

return (
<AntForm
{...formProps}
onSubmit={this._onSubmit}
>
{children}
{this._renderSubmitButton()}
</AntForm>
);
}

getChildContext() {
return {
schema: this.props.resourceForm.schema,
defaultItemProps: this.props.defaultItemProps
}
}

protected _renderSubmitButton() {
const {
submitButton = DEFAULT_SUBMIT_BUTTON
} = this.props;

if (submitButton === false) {
return;
}

return React.cloneElement(submitButton, {
loading: this.state.submitting,
htmlType: 'submit'
});
}

protected _validate = () =>
new Promise<Data>((resolve, reject) => {
(this.props.form as WrappedFormUtils)
.validateFields((err, values) => {
if (err) {
return reject(Error('One or more fields are invalid'));
}

resolve(values);
});
})

protected _onSubmit: React.EventHandler<React.FormEvent<any>> = e => {
console.log('SUBMIT');
e.preventDefault();

this.setState({ submitting: true });

this._validate()
.then(values => {
return this.props.resourceForm.submit(values);
})
.then(result => {
if (this.props.onSuccess) {
return this.props.onSuccess(result);
}
})
.catch(error => {
if (this.props.onFailure) {
return this.props.onFailure(error);
}

throw error;
})
.finally(() => {
this.setState({ submitting: false });
});
}
}

export const Form = AntForm.create()(FormComponent as any) as React.ComponentClass<Props>;
13 changes: 13 additions & 0 deletions components/LoginForm.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@import "~antd/lib/style/themes/default";

.login-form {
width: 320px;

&__alert {
margin-bottom: @form-item-margin-bottom;
}

&__submit-button {
width: 100%;
}
}
Loading

0 comments on commit 8a57fd1

Please sign in to comment.