diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..212eb27
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+REACT_APP_DEFAULT_LANGUAGE=en
+REACT_APP_API_ENDPOINT=
+REACT_APP_API_CLIENT_ID=
+REACT_APP_API_CLIENT_SECRET=
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 824e125..93d1992 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,6 +2,11 @@ name: Test
on: pull_request
+env:
+ REACT_APP_API_ENDPOINT: ${{ secrets.API_ENDPOINT }}
+ REACT_APP_API_CLIENT_ID: ${{ secrets.API_CLIENT_ID }}
+ REACT_APP_API_CLIENT_SECRET: ${{ secrets.API_CLIENT_SECRET }}
+
jobs:
test:
name: Run linters and tests
@@ -33,6 +38,14 @@ jobs:
- name: Run integration tests
run: npm run cypress
+ - name: Upload any cypress failures
+ uses: actions/upload-artifact@v3
+ if: failure()
+ with:
+ name: cypress-output
+ path: cypress/videos
+ retention-days: 5
+
- name: Merge code coverage reports
run: npm run test:merge-coverage
diff --git a/.gitignore b/.gitignore
index e223f6b..d28129b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@
# misc
.DS_Store
+.env
.env.local
.env.development.local
.env.test.local
diff --git a/cypress/fixtures/Authentication/invalid-credentials.json b/cypress/fixtures/Authentication/invalid-credentials.json
new file mode 100644
index 0000000..c174597
--- /dev/null
+++ b/cypress/fixtures/Authentication/invalid-credentials.json
@@ -0,0 +1 @@
+{ "errors": [{ "detail": "Your email or password is incorrect. Please try again.", "code": "invalid_email_or_password" }] }
diff --git a/cypress/fixtures/Authentication/valid-credentials.json b/cypress/fixtures/Authentication/valid-credentials.json
new file mode 100644
index 0000000..b7ce2bf
--- /dev/null
+++ b/cypress/fixtures/Authentication/valid-credentials.json
@@ -0,0 +1,13 @@
+{
+ "data": {
+ "id": "18422",
+ "type": "token",
+ "attributes": {
+ "access_token": "3XwBqiXrM-xhHCc-0QCKsOuE0Xp9AYdYTiZwTSqo4M4",
+ "token_type": "Bearer",
+ "expires_in": 7200,
+ "refresh_token": "QOiwCsTV7Wn4mO56Kz-4rj8nGCWfVMcIwSbKo4yicEk",
+ "created_at": 1677124678
+ }
+ }
+}
diff --git a/cypress/integration/Authentication/login.spec.ts b/cypress/integration/Authentication/login.spec.ts
new file mode 100644
index 0000000..f6e3ba1
--- /dev/null
+++ b/cypress/integration/Authentication/login.spec.ts
@@ -0,0 +1,72 @@
+describe('User Authentication', () => {
+ context('upon navigation to /login', () => {
+ it('displays login page', () => {
+ cy.visit('/login');
+
+ cy.findByTestId('login-header').should('be.visible');
+ });
+ });
+
+ // TODO: The below test fail on CI/CD with the use of Intercept
+
+ context('given valid credentials', () => {
+ it('redirects to the home page', () => {
+ cy.intercept('POST', '/oauth/token/', {
+ statusCode: 200,
+ fixture: 'Authentication/valid-credentials.json',
+ });
+
+ cy.visit('/login');
+
+ cy.get('input[name=email]').type('liam@nimblehq.co');
+ cy.get('input[name=password]').type('12345678');
+ cy.get('button[type="submit"]').click();
+
+ cy.location().should((location) => {
+ expect(location.pathname).to.eq('/');
+ });
+ });
+ });
+
+ context('given NO credentials entered', () => {
+ it('shows field validation errors', () => {
+ cy.visit('/login');
+
+ cy.get('button[type="submit"]').click();
+
+ cy.get('.errors').should('be.visible');
+
+ cy.get('.errors').within(() => {
+ cy.contains('Email has invalid format');
+ cy.contains('Password should be at least');
+ });
+ });
+ });
+
+ // TODO: The below test fail on CI/CD with the use of Intercept
+
+ context('given INVALID credentials', () => {
+ it('shows login error', () => {
+ cy.intercept('POST', '/oauth/token/', {
+ statusCode: 400,
+ fixture: 'Authentication/invalid-credentials.json',
+ });
+
+ cy.visit('/login');
+
+ cy.get('input[name=email]').type('testemail@gmail.com');
+ cy.get('input[name=password]').type('password123');
+ cy.get('button[type="submit"]').click();
+
+ cy.location().should((location) => {
+ expect(location.pathname).to.eq('/login');
+ });
+
+ cy.get('.errors').should('be.visible');
+
+ cy.get('.errors').within(() => {
+ cy.findByText('Your email or password is incorrect. Please try again.').should('exist');
+ });
+ });
+ });
+});
diff --git a/cypress/integration/UserAuthentication/login.spec.ts b/cypress/integration/UserAuthentication/login.spec.ts
deleted file mode 100644
index 651d91d..0000000
--- a/cypress/integration/UserAuthentication/login.spec.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-describe('User authentication', () => {
- context('upon naivation to /login', () => {
- it('displays login page', () => {
- cy.visit('/login');
-
- cy.findByTestId('login-header').should('be.visible');
- });
- });
-});
diff --git a/package-lock.json b/package-lock.json
index 57a326b..3a221b0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,9 +38,12 @@
"danger": "10.9.0",
"danger-plugin-istanbul-coverage": "1.6.2",
"eslint": "8.11.0",
+ "jest-localstorage-mock": "^2.4.26",
+ "nock": "13.3.0",
"postcss": "8.4.21",
"postcss-import": "14.1.0",
"prettier": "2.6.0",
+ "prettier-plugin-tailwindcss": "0.2.3",
"start-server-and-test": "1.14.0",
"stylelint": "14.6.0",
"tailwindcss": "3.2.6",
@@ -14215,6 +14218,15 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
+ "node_modules/jest-localstorage-mock": {
+ "version": "2.4.26",
+ "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz",
+ "integrity": "sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.16.0"
+ }
+ },
"node_modules/jest-matcher-utils": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz",
@@ -16752,6 +16764,21 @@
"tslib": "^2.0.3"
}
},
+ "node_modules/nock": {
+ "version": "13.3.0",
+ "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.0.tgz",
+ "integrity": "sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.1.0",
+ "json-stringify-safe": "^5.0.1",
+ "lodash": "^4.17.21",
+ "propagate": "^2.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13"
+ }
+ },
"node_modules/node-cleanup": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz",
@@ -19146,6 +19173,80 @@
"node": ">=6.0.0"
}
},
+ "node_modules/prettier-plugin-tailwindcss": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.3.tgz",
+ "integrity": "sha512-s2N5Dh7Ao5KTV1mao5ZBnn8EKtUcDPJEkGViZIjI0Ij9TTI5zgTz4IHOxW33jOdjHKa8CSjM88scelUiC5TNRQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.17.0"
+ },
+ "peerDependencies": {
+ "@ianvs/prettier-plugin-sort-imports": "*",
+ "@prettier/plugin-php": "*",
+ "@prettier/plugin-pug": "*",
+ "@shopify/prettier-plugin-liquid": "*",
+ "@shufo/prettier-plugin-blade": "*",
+ "@trivago/prettier-plugin-sort-imports": "*",
+ "prettier": ">=2.2.0",
+ "prettier-plugin-astro": "*",
+ "prettier-plugin-css-order": "*",
+ "prettier-plugin-import-sort": "*",
+ "prettier-plugin-jsdoc": "*",
+ "prettier-plugin-organize-attributes": "*",
+ "prettier-plugin-organize-imports": "*",
+ "prettier-plugin-style-order": "*",
+ "prettier-plugin-svelte": "*",
+ "prettier-plugin-twig-melody": "*"
+ },
+ "peerDependenciesMeta": {
+ "@ianvs/prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "@prettier/plugin-php": {
+ "optional": true
+ },
+ "@prettier/plugin-pug": {
+ "optional": true
+ },
+ "@shopify/prettier-plugin-liquid": {
+ "optional": true
+ },
+ "@shufo/prettier-plugin-blade": {
+ "optional": true
+ },
+ "@trivago/prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "prettier-plugin-astro": {
+ "optional": true
+ },
+ "prettier-plugin-css-order": {
+ "optional": true
+ },
+ "prettier-plugin-import-sort": {
+ "optional": true
+ },
+ "prettier-plugin-jsdoc": {
+ "optional": true
+ },
+ "prettier-plugin-organize-attributes": {
+ "optional": true
+ },
+ "prettier-plugin-organize-imports": {
+ "optional": true
+ },
+ "prettier-plugin-style-order": {
+ "optional": true
+ },
+ "prettier-plugin-svelte": {
+ "optional": true
+ },
+ "prettier-plugin-twig-melody": {
+ "optional": true
+ }
+ }
+ },
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -19264,6 +19365,15 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
+ "node_modules/propagate": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
+ "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -34696,6 +34806,12 @@
"pretty-format": "^27.5.1"
}
},
+ "jest-localstorage-mock": {
+ "version": "2.4.26",
+ "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz",
+ "integrity": "sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w==",
+ "dev": true
+ },
"jest-matcher-utils": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz",
@@ -36633,6 +36749,18 @@
"tslib": "^2.0.3"
}
},
+ "nock": {
+ "version": "13.3.0",
+ "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.0.tgz",
+ "integrity": "sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg==",
+ "dev": true,
+ "requires": {
+ "debug": "^4.1.0",
+ "json-stringify-safe": "^5.0.1",
+ "lodash": "^4.17.21",
+ "propagate": "^2.0.0"
+ }
+ },
"node-cleanup": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz",
@@ -38197,6 +38325,13 @@
"fast-diff": "^1.1.2"
}
},
+ "prettier-plugin-tailwindcss": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.3.tgz",
+ "integrity": "sha512-s2N5Dh7Ao5KTV1mao5ZBnn8EKtUcDPJEkGViZIjI0Ij9TTI5zgTz4IHOxW33jOdjHKa8CSjM88scelUiC5TNRQ==",
+ "dev": true,
+ "requires": {}
+ },
"pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -38292,6 +38427,12 @@
}
}
},
+ "propagate": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
+ "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
+ "dev": true
+ },
"proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
diff --git a/package.json b/package.json
index a90b3d3..847d51b 100644
--- a/package.json
+++ b/package.json
@@ -65,15 +65,19 @@
"danger": "10.9.0",
"danger-plugin-istanbul-coverage": "1.6.2",
"eslint": "8.11.0",
+ "jest-localstorage-mock": "2.4.26",
+ "nock": "13.3.0",
"postcss": "8.4.21",
"postcss-import": "14.1.0",
"prettier": "2.6.0",
+ "prettier-plugin-tailwindcss": "0.2.3",
"start-server-and-test": "1.14.0",
"stylelint": "14.6.0",
"tailwindcss": "3.2.6",
"typescript": "4.6.2"
},
"jest": {
+ "resetMocks": false,
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
diff --git a/public/_redirects b/public/_redirects
new file mode 100644
index 0000000..4aa7faf
--- /dev/null
+++ b/public/_redirects
@@ -0,0 +1 @@
+/* /index.html 200
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 8f819dd..332ce1f 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -1,8 +1,11 @@
{
"login": {
- "sign_in": "Sign in",
+ "sign-in": "Sign in",
"email": "Email",
"password": "Password",
- "forgot_password": "Forgot?"
+ "forgot-password": "Forgot?",
+ "invalid-email": "Email has invalid format",
+ "invalid-password": "Password should be at least {{passwordMinLength}}",
+ "generic-server-error": "There was a problem receiving a response from the server"
}
}
diff --git a/src/App.tsx b/src/App.tsx
index 0586ea2..2859b18 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -10,7 +10,7 @@ const App = (): JSX.Element => {
return (
- {appRoutes}
+ {appRoutes}
);
};
diff --git a/src/adapters/authAdapter.test.ts b/src/adapters/authAdapter.test.ts
new file mode 100644
index 0000000..a863c21
--- /dev/null
+++ b/src/adapters/authAdapter.test.ts
@@ -0,0 +1,42 @@
+import nock from 'nock';
+
+import AuthAdapter from './authAdapter';
+
+/* eslint-disable camelcase */
+const testCredentials = {
+ email: 'testemail@gmail.com',
+ password: 'password123',
+};
+
+const commonLoginParams = {
+ grant_type: 'password',
+ client_id: process.env.REACT_APP_API_CLIENT_ID,
+ client_secret: process.env.REACT_APP_API_CLIENT_SECRET,
+};
+/* eslint-enable camelcase */
+
+describe('AuthAdapter', () => {
+ afterAll(() => {
+ nock.cleanAll();
+ nock.restore();
+ });
+
+ describe('login', () => {
+ test('The login endpoint is called with credientials from the request', async () => {
+ const scope = nock(`${process.env.REACT_APP_API_ENDPOINT}`)
+ .defaultReplyHeaders({
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-credentials': 'true',
+ })
+ .post('/oauth/token', {
+ ...testCredentials,
+ ...commonLoginParams,
+ })
+ .reply(200);
+
+ expect(scope.isDone()).toBe(false);
+ await AuthAdapter.login({ ...testCredentials });
+ expect(scope.isDone()).toBe(true);
+ });
+ });
+});
diff --git a/src/adapters/authAdapter.ts b/src/adapters/authAdapter.ts
new file mode 100644
index 0000000..cee734a
--- /dev/null
+++ b/src/adapters/authAdapter.ts
@@ -0,0 +1,23 @@
+import BaseAdapter from './baseAdapter.';
+
+type LoginAuthType = {
+ email: string;
+ password: string;
+};
+
+class AuthAdapter extends BaseAdapter {
+ static login(params: LoginAuthType) {
+ /* eslint-disable camelcase */
+ const requestParams = {
+ ...params,
+ grant_type: 'password',
+ client_id: process.env.REACT_APP_API_CLIENT_ID,
+ client_secret: process.env.REACT_APP_API_CLIENT_SECRET,
+ };
+ /* eslint-enable camelcase */
+
+ return this.prototype.postRequest('oauth/token', { data: requestParams });
+ }
+}
+
+export default AuthAdapter;
diff --git a/src/adapters/baseAdapter..ts b/src/adapters/baseAdapter..ts
new file mode 100644
index 0000000..2cf9ff3
--- /dev/null
+++ b/src/adapters/baseAdapter..ts
@@ -0,0 +1,15 @@
+import requestManager, { RequestParamsType } from 'lib/requestManager';
+
+class BaseAdapter {
+ static baseUrl = process.env.REACT_APP_API_ENDPOINT;
+
+ getRequest(endpoint: string, requestOption: RequestParamsType) {
+ return requestManager('GET', `${BaseAdapter.baseUrl}/${endpoint}`, requestOption);
+ }
+
+ postRequest(endpoint: string, requestOption: RequestParamsType) {
+ return requestManager('POST', `${BaseAdapter.baseUrl}/${endpoint}`, requestOption);
+ }
+}
+
+export default BaseAdapter;
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index c1722dd..75ff688 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -2,14 +2,20 @@ import styles from './Button.module.scss';
type ButtonProps = {
text: string;
- type: 'button' | 'submit' | 'reset';
+ type?: 'button' | 'submit' | 'reset';
className?: string;
- onButtonClick: () => void;
+ disabled?: boolean;
+ onButtonClick?: () => void;
};
-function Button({ text, type, className, onButtonClick }: ButtonProps) {
+function Button({ text, type, className, disabled, onButtonClick }: ButtonProps) {
return (
-