diff --git a/.eslintignore b/.eslintignore index 0800076..fb02106 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,3 +10,6 @@ build # Ignore Logs logs *.log + +# coverage test directory +coverage diff --git a/.eslintrc b/.eslintrc index 2225fd6..762feb8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,3 +1,11 @@ { - "extends": ["standard", "standard-react"] + "extends": ["standard", "standard-react"], + "settings": { + "react": { + "version": "16.4.0" + } + }, + "env": { + "jest": true + } } diff --git a/.gitignore b/.gitignore index a955a19..18817a9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ build # distribution directory dist + +# coverage test directory +coverage diff --git a/.travis.yml b/.travis.yml index d418413..6ee8dab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,3 +12,4 @@ install: script: - npm run lint - npm run build + - npm run test diff --git a/AUTHORS.md b/AUTHORS.md index 0ce9c6a..16075b4 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -14,6 +14,7 @@ If you make a contribution here, you may add your name and email address here. - Austin Daniel (austindanielfrench@gmail.com) - Bekir Durak (bekirdurak97@gmail.com) - Andrew Cheng (cheng0807@gmail.com) +- Mahmoud Younes (m.younesbadr@gmail.com) diff --git a/README.md b/README.md index 9ccb465..da29c22 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ ### Getting started -Check [Our Docs](./docs/index.md) for **building** and **installing** instructions. +Check [Our Docs](./docs/README.md) for **building** and **installing** instructions. -If you would like to contribute to Deer, see [Our Docs](./docs/index.md) too. +If you would like to contribute to Deer, see [Our Docs](./docs/README.md) too. ### Get in touch! diff --git a/__mocks__/electron.js b/__mocks__/electron.js new file mode 100644 index 0000000..e1c2053 --- /dev/null +++ b/__mocks__/electron.js @@ -0,0 +1,5 @@ +module.exports = { + remote: { + getGlobal: jest.fn() + } +} diff --git a/__mocks__/pouchdb-browser.js b/__mocks__/pouchdb-browser.js new file mode 100644 index 0000000..a3edaa5 --- /dev/null +++ b/__mocks__/pouchdb-browser.js @@ -0,0 +1,3 @@ +module.exports = jest.fn(() => ({ + name: 'test' +})) diff --git a/__mocks__/uuid/v4.js b/__mocks__/uuid/v4.js new file mode 100644 index 0000000..6200f47 --- /dev/null +++ b/__mocks__/uuid/v4.js @@ -0,0 +1 @@ +module.exports = jest.fn(() => 'id-string') diff --git a/__tests__/actions/__snapshots__/header.test.js.snap b/__tests__/actions/__snapshots__/header.test.js.snap new file mode 100644 index 0000000..ef71de6 --- /dev/null +++ b/__tests__/actions/__snapshots__/header.test.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`header_actions setDeleteDisabled Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": true, + "type": "UPDATE_DELETE_NOTE_STATE", + }, +] +`; + +exports[`header_actions setNewNoteDisabled Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": true, + "type": "UPDATE_NEW_NOTE_STATE", + }, +] +`; + +exports[`header_actions setSaveDisabled Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": true, + "type": "UPDATE_SAVE_NOTE_STATE", + }, +] +`; diff --git a/__tests__/actions/__snapshots__/modal.test.js.snap b/__tests__/actions/__snapshots__/modal.test.js.snap new file mode 100644 index 0000000..407b785 --- /dev/null +++ b/__tests__/actions/__snapshots__/modal.test.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`modal_actions toggleYesNoModal Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": "SAVE_NOTE", + "type": "TOGGLE_SAVE_MODAL", + }, +] +`; diff --git a/__tests__/actions/__snapshots__/note.test.js.snap b/__tests__/actions/__snapshots__/note.test.js.snap new file mode 100644 index 0000000..b0c52de --- /dev/null +++ b/__tests__/actions/__snapshots__/note.test.js.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`note_actions addNewNote Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "type": "ADD_NOTE", + }, +] +`; + +exports[`note_actions deleteNoteFromList Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": 3, + "type": "DELETE_NOTE_FROM_LIST", + }, +] +`; + +exports[`note_actions loadNoteContent Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": Object {}, + "type": "LOAD_NOTE_CONTENT", + }, +] +`; + +exports[`note_actions setActiveNoteIndex Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": 3, + "type": "SET_ACTIVE_NOTE", + }, +] +`; + +exports[`note_actions setNoteStatus Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": "SAVING_NOTE", + "type": "SET_NOTE_STATUS", + }, +] +`; + +exports[`note_actions updateActiveNoteState Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": Object {}, + "type": "UPDATE_ACTIVE_NOTE_STATE", + }, +] +`; + +exports[`note_actions updateNoteList Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "type": "UPDATE_NOTE_LIST", + }, +] +`; + +exports[`note_actions updateNoteRev Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": "a123-a123", + "type": "UPDATE_NOTE_REV", + }, +] +`; + +exports[`note_actions updateNoteTitle Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "payload": "Hello world", + "type": "UPDATE_NOTE_TITLE", + }, +] +`; diff --git a/__tests__/actions/__snapshots__/welcome.test.js.snap b/__tests__/actions/__snapshots__/welcome.test.js.snap new file mode 100644 index 0000000..92e7902 --- /dev/null +++ b/__tests__/actions/__snapshots__/welcome.test.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`welcome_actions getNextLang Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "type": "SET_NEXT_LANG", + }, +] +`; + +exports[`welcome_actions toggleFade Dispatches the correct action and payload 1`] = ` +Array [ + Object { + "type": "TOGGLE_FADE", + }, +] +`; diff --git a/__tests__/actions/header.test.js b/__tests__/actions/header.test.js new file mode 100644 index 0000000..f87b598 --- /dev/null +++ b/__tests__/actions/header.test.js @@ -0,0 +1,34 @@ +import configureStore from 'redux-mock-store' + +// Actions to be tested +import * as headerActions from '../../app/actions/header' + +const mockStore = configureStore() +const store = mockStore() + +describe('header_actions', () => { + beforeEach(() => { + store.clearActions() + }) + + describe('setNewNoteDisabled', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(headerActions.setNewNoteDisabled(true)) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('setSaveDisabled', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(headerActions.setSaveDisabled(true)) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('setDeleteDisabled', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(headerActions.setDeleteDisabled(true)) + expect(store.getActions()).toMatchSnapshot() + }) + }) +}) diff --git a/__tests__/actions/modal.test.js b/__tests__/actions/modal.test.js new file mode 100644 index 0000000..6f774fe --- /dev/null +++ b/__tests__/actions/modal.test.js @@ -0,0 +1,22 @@ +import configureStore from 'redux-mock-store' +import { ACTIONS } from '../../app/constants/actions' +import * as modalActions from '../../app/actions/modal' +jest.mock('../../__mocks__/pouchdb-browser') + +const mockStore = configureStore() +const store = mockStore() + +global.fetch = () => {} + +describe('modal_actions', () => { + beforeEach(() => { + store.clearActions() + }) + + describe('toggleYesNoModal', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(modalActions.toggleYesNoModal(ACTIONS.SAVE_NOTE)) + expect(store.getActions()).toMatchSnapshot() + }) + }) +}) diff --git a/__tests__/actions/note.test.js b/__tests__/actions/note.test.js new file mode 100644 index 0000000..d63a740 --- /dev/null +++ b/__tests__/actions/note.test.js @@ -0,0 +1,77 @@ +import configureStore from 'redux-mock-store' + +// Actions to be tested +import * as noteActions from '../../app/actions/note' +import { NOTE_STATUS } from '../../app/constants/noteStatus' + +const mockStore = configureStore() +const store = mockStore() + +describe('note_actions', () => { + beforeEach(() => { + store.clearActions() + }) + + describe('addNewNote', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.addNewNote()) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('updateNoteList', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.updateNoteList()) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('setActiveNoteIndex', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.setActiveNoteIndex(3)) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('updateNoteTitle', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.updateNoteTitle('Hello world')) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('updateActiveNoteState', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.updateActiveNoteState({})) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('updateNoteRev', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.updateNoteRev('a123-a123')) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('setNoteStatus', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.setNoteStatus(NOTE_STATUS.SAVING_NOTE)) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('loadNoteContent', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.loadNoteContent({})) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('deleteNoteFromList', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(noteActions.deleteNoteFromList(3)) + expect(store.getActions()).toMatchSnapshot() + }) + }) +}) diff --git a/__tests__/actions/welcome.test.js b/__tests__/actions/welcome.test.js new file mode 100644 index 0000000..98a56f6 --- /dev/null +++ b/__tests__/actions/welcome.test.js @@ -0,0 +1,27 @@ +import configureStore from 'redux-mock-store' + +// Actions to be tested +import * as welcomeActions from '../../app/actions/welcome' + +const mockStore = configureStore() +const store = mockStore() + +describe('welcome_actions', () => { + beforeEach(() => { + store.clearActions() + }) + + describe('getNextLang', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(welcomeActions.getNextLang()) + expect(store.getActions()).toMatchSnapshot() + }) + }) + + describe('toggleFade', () => { + test('Dispatches the correct action and payload', () => { + store.dispatch(welcomeActions.toggleFade()) + expect(store.getActions()).toMatchSnapshot() + }) + }) +}) diff --git a/__tests__/components/__snapshots__/app.test.js.snap b/__tests__/components/__snapshots__/app.test.js.snap new file mode 100644 index 0000000..6da3d4a --- /dev/null +++ b/__tests__/components/__snapshots__/app.test.js.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` render() renders the component 1`] = ` + + + + + + + +`; diff --git a/__tests__/components/app.test.js b/__tests__/components/app.test.js new file mode 100644 index 0000000..e13b464 --- /dev/null +++ b/__tests__/components/app.test.js @@ -0,0 +1,17 @@ +import React from 'react' +import { shallow } from 'enzyme' +import toJson from 'enzyme-to-json' +import App from '../../app/components/App' + +jest.mock('../../__mocks__/electron') + +describe('', () => { + describe('render()', () => { + test('renders the component', () => { + const wrapper = shallow() + const component = wrapper.dive() + + expect(toJson(component)).toMatchSnapshot() + }) + }) +}) diff --git a/__tests__/reducers/__snapshots__/header.test.js.snap b/__tests__/reducers/__snapshots__/header.test.js.snap new file mode 100644 index 0000000..01ef2f9 --- /dev/null +++ b/__tests__/reducers/__snapshots__/header.test.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`INITIAL_STATE is correct 1`] = ` +Object { + "isDeleteDisabled": true, + "isNewNoteDisabled": false, + "isSaveDisabled": true, +} +`; + +exports[`UPDATE_DELETE_NOTE_STATE is correct 1`] = ` +Object { + "isDeleteDisabled": false, + "isNewNoteDisabled": false, + "isSaveDisabled": true, +} +`; + +exports[`UPDATE_NEW_NOTE_STATE is correct 1`] = ` +Object { + "isDeleteDisabled": true, + "isNewNoteDisabled": true, + "isSaveDisabled": true, +} +`; + +exports[`UPDATE_SAVE_NOTE_STATE is correct 1`] = ` +Object { + "isDeleteDisabled": true, + "isNewNoteDisabled": false, + "isSaveDisabled": false, +} +`; diff --git a/__tests__/reducers/__snapshots__/modal.test.js.snap b/__tests__/reducers/__snapshots__/modal.test.js.snap new file mode 100644 index 0000000..09dfcae --- /dev/null +++ b/__tests__/reducers/__snapshots__/modal.test.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`INITIAL_STATE is correct 1`] = ` +Object { + "showYesNoModal": false, + "yesNoAction": "NO_ACTION", +} +`; + +exports[`TOGGLE_SAVE_MODAL is correct 1`] = ` +Object { + "showYesNoModal": true, + "yesNoAction": "SAVE_NOTE", +} +`; diff --git a/__tests__/reducers/__snapshots__/note.test.js.snap b/__tests__/reducers/__snapshots__/note.test.js.snap new file mode 100644 index 0000000..3de987b --- /dev/null +++ b/__tests__/reducers/__snapshots__/note.test.js.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ADD_NOTE is correct 1`] = ` +Object { + "activeNoteIndex": 0, + "noteStatus": "NO_OPERATION", + "notes": Array [ + Object { + "id": "id-string", + "rev": "", + "title": "", + }, + ], +} +`; + +exports[`DELETE_NOTE_FROM_LIST is correct 1`] = ` +Object { + "activeNoteIndex": -1, + "noteStatus": "NO_OPERATION", + "notes": Array [], +} +`; + +exports[`INITIAL_STATE is correct 1`] = ` +Object { + "activeNoteIndex": -1, + "noteStatus": "NO_OPERATION", + "notes": Array [], +} +`; + +exports[`SET_ACTIVE_NOTE_INDEX is correct 1`] = ` +Object { + "activeNoteIndex": 3, + "noteStatus": "NO_OPERATION", + "notes": Array [], +} +`; + +exports[`SET_NOTE_STATUS is correct 1`] = ` +Object { + "activeNoteIndex": -1, + "noteStatus": "SAVING_NOTE", + "notes": Array [], +} +`; + +exports[`UPDATE_ACTIVE_NOTE_STATE is correct 1`] = ` +Object { + "activeNoteIndex": -1, + "noteStatus": "NO_OPERATION", + "notes": Array [], +} +`; + +exports[`UPDATE_NOTE_LIST is correct 1`] = ` +Object { + "activeNoteIndex": -1, + "noteStatus": "NO_OPERATION", + "notes": Array [ + Object { + "id": "123", + "rev": "123a", + "title": "welcome to Deer", + }, + ], +} +`; + +exports[`UPDATE_NOTE_REV is correct 1`] = ` +Object { + "activeNoteIndex": -1, + "noteStatus": "NO_OPERATION", + "notes": Array [], +} +`; + +exports[`UPDATE_NOTE_TITLE is correct 1`] = ` +Object { + "activeNoteIndex": -1, + "noteStatus": "NO_OPERATION", + "notes": Array [], +} +`; diff --git a/__tests__/reducers/__snapshots__/welcome.test.js.snap b/__tests__/reducers/__snapshots__/welcome.test.js.snap new file mode 100644 index 0000000..69ef76a --- /dev/null +++ b/__tests__/reducers/__snapshots__/welcome.test.js.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GET_NEXT_LANG is correct 1`] = ` +Object { + "fadeIn": true, + "index": 0, +} +`; + +exports[`INITIAL_STATE is correct 1`] = ` +Object { + "fadeIn": true, + "index": -1, + "langList": Array [ + Object { + "lang": "English", + "nextBtn": "Next", + "welcome": "Welcome", + }, + Object { + "lang": "Arabic", + "nextBtn": "التالى", + "welcome": "أهلا بك", + }, + Object { + "lang": "French", + "nextBtn": "Prochain", + "welcome": "Bienvenue", + }, + Object { + "lang": "Spanish", + "nextBtn": "Siguiente", + "welcome": "Bienvenido", + }, + Object { + "lang": "Italian", + "nextBtn": "Il prossimo", + "welcome": "Benvenuto", + }, + Object { + "lang": "German", + "nextBtn": "Nächster", + "welcome": "Willkommen", + }, + Object { + "lang": "Russian", + "nextBtn": "следующий", + "welcome": "пожаловать", + }, + Object { + "lang": "Chinese", + "nextBtn": "下一个", + "welcome": "欢迎", + }, + Object { + "lang": "Japanese", + "nextBtn": "次", + "welcome": "ようこそ", + }, + Object { + "lang": "Hindi", + "nextBtn": "आगामी", + "welcome": "स्वागत हे", + }, + ], +} +`; + +exports[`TOGGLE_FADE is correct 1`] = ` +Object { + "fadeIn": false, + "index": -1, +} +`; diff --git a/__tests__/reducers/header.test.js b/__tests__/reducers/header.test.js new file mode 100644 index 0000000..3257e40 --- /dev/null +++ b/__tests__/reducers/header.test.js @@ -0,0 +1,35 @@ + +import headerReducer from '../../app/reducers/header' +import { ACTIONS } from '../../app/constants/actions' + +describe('INITIAL_STATE', () => { + test('is correct', () => { + const action = { } + + expect(headerReducer(undefined, action)).toMatchSnapshot() + }) +}) + +describe('UPDATE_NEW_NOTE_STATE', () => { + test('is correct', () => { + const action = { type: ACTIONS.UPDATE_NEW_NOTE_STATE, payload: true } + + expect(headerReducer(undefined, action)).toMatchSnapshot() + }) +}) + +describe('UPDATE_SAVE_NOTE_STATE', () => { + test('is correct', () => { + const action = { type: ACTIONS.UPDATE_SAVE_NOTE_STATE, payload: false } + + expect(headerReducer(undefined, action)).toMatchSnapshot() + }) +}) + +describe('UPDATE_DELETE_NOTE_STATE', () => { + test('is correct', () => { + const action = { type: ACTIONS.UPDATE_DELETE_NOTE_STATE, payload: false } + + expect(headerReducer(undefined, action)).toMatchSnapshot() + }) +}) diff --git a/__tests__/reducers/modal.test.js b/__tests__/reducers/modal.test.js new file mode 100644 index 0000000..dc6d3a1 --- /dev/null +++ b/__tests__/reducers/modal.test.js @@ -0,0 +1,22 @@ + +import modalReducer from '../../app/reducers/modal' +import { ACTIONS } from '../../app/constants/actions' + +describe('INITIAL_STATE', () => { + test('is correct', () => { + const action = { } + + expect(modalReducer(undefined, action)).toMatchSnapshot() + }) +}) + +describe('TOGGLE_SAVE_MODAL', () => { + test('is correct', () => { + const action = { + type: ACTIONS.TOGGLE_SAVE_MODAL, + payload: ACTIONS.SAVE_NOTE + } + + expect(modalReducer(undefined, action)).toMatchSnapshot() + }) +}) diff --git a/__tests__/reducers/note.test.js b/__tests__/reducers/note.test.js new file mode 100644 index 0000000..1da20cb --- /dev/null +++ b/__tests__/reducers/note.test.js @@ -0,0 +1,110 @@ +import noteReducer from '../../app/reducers/note' +import { ACTIONS } from '../../app/constants/actions' +import { NOTE_STATUS } from '../../app/constants/noteStatus' + +jest.mock('../../__mocks__/uuid/v4') + +describe('INITIAL_STATE', () => { + test('is correct', () => { + const action = { } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) + +describe('UPDATE_NOTE_LIST', () => { + test('is correct', () => { + const notes = [ + { + doc: { + _id: '123', + _rev: '123a', + title: 'welcome to Deer' + } + } + ] + const action = { type: ACTIONS.UPDATE_NOTE_LIST, payload: notes } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) + +describe('SET_ACTIVE_NOTE_INDEX', () => { + test('is correct', () => { + const action = { type: ACTIONS.SET_ACTIVE_NOTE_INDEX, payload: 3 } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) + +describe('ADD_NOTE', () => { + test('is correct', () => { + const action = { type: ACTIONS.ADD_NOTE } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) + +describe('UPDATE_NOTE_TITLE', () => { + test('is correct', () => { + const action = { type: ACTIONS.UPDATE_NOTE_TITLE, payload: 'Hello World' } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) + +describe('UPDATE_NOTE_REV', () => { + test('is correct', () => { + const action = { type: ACTIONS.UPDATE_NOTE_REV, payload: 'a123-k123' } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) + +describe('UPDATE_ACTIVE_NOTE_STATE', () => { + test('is correct', () => { + const action = { + type: ACTIONS.UPDATE_ACTIVE_NOTE_STATE, + payload: 'new State' + } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) + +describe('SET_NOTE_STATUS', () => { + test('is correct', () => { + const action = { + type: ACTIONS.SET_NOTE_STATUS, + payload: NOTE_STATUS.SAVING_NOTE + } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) + +describe('DELETE_NOTE_FROM_LIST', () => { + test('is correct', () => { + const action = { type: ACTIONS.DELETE_NOTE_FROM_LIST } + + const result = noteReducer(undefined, action) + delete result.activeNoteState + expect(result).toMatchSnapshot() + }) +}) diff --git a/__tests__/reducers/welcome.test.js b/__tests__/reducers/welcome.test.js new file mode 100644 index 0000000..3e55b67 --- /dev/null +++ b/__tests__/reducers/welcome.test.js @@ -0,0 +1,30 @@ +import welcomeReducer from '../../app/reducers/welcome' +import { ACTIONS } from '../../app/constants/actions' + +describe('INITIAL_STATE', () => { + test('is correct', () => { + const action = { } + + expect(welcomeReducer(undefined, action)).toMatchSnapshot() + }) +}) + +describe('GET_NEXT_LANG', () => { + test('is correct', () => { + const action = { type: ACTIONS.GET_NEXT_LANG } + + const result = welcomeReducer(undefined, action) + delete result.langList + expect(result).toMatchSnapshot() + }) +}) + +describe('TOGGLE_FADE', () => { + test('is correct', () => { + const action = { type: ACTIONS.TOGGLE_FADE } + + const result = welcomeReducer(undefined, action) + delete result.langList + expect(result).toMatchSnapshot() + }) +}) diff --git a/__tests__/setup.js b/__tests__/setup.js new file mode 100644 index 0000000..f22fd8d --- /dev/null +++ b/__tests__/setup.js @@ -0,0 +1,5 @@ +import Enzyme from 'enzyme' +import Adapter from 'enzyme-adapter-react-16' + +// React 16 Enzyme adapter +Enzyme.configure({ adapter: new Adapter() }) diff --git a/app/actions/header.js b/app/actions/header.js new file mode 100644 index 0000000..2ee7430 --- /dev/null +++ b/app/actions/header.js @@ -0,0 +1,11 @@ +import { createAction } from 'redux-actions' +import { ACTIONS } from '../constants/actions' + +// Used for updating state of adding new note button (e.g. enabled or disabled). +export const setNewNoteDisabled = createAction(ACTIONS.UPDATE_NEW_NOTE_STATE) + +// Used for updating state of save button (e.g. enabled or disabled). +export const setSaveDisabled = createAction(ACTIONS.UPDATE_SAVE_NOTE_STATE) + +// Used for updating state of deleteing note button (e.g. enabled or disabled). +export const setDeleteDisabled = createAction(ACTIONS.UPDATE_DELETE_NOTE_STATE) diff --git a/app/actions/modal.js b/app/actions/modal.js new file mode 100644 index 0000000..b8106bb --- /dev/null +++ b/app/actions/modal.js @@ -0,0 +1,19 @@ +import { createAction } from 'redux-actions' +import { ACTIONS } from '../constants/actions' +import { NOTE_STATUS } from '../constants/noteStatus' +import { setNoteStatus } from './note' + +// Used for updating visibility of save modal. +export const toggleYesNoModal = createAction(ACTIONS.TOGGLE_SAVE_MODAL) + +// Async method, Used for updating note status with two optional parameters +// - withTimeOut: dispatch action after timeout or not and it's default value +// is true. +// - status: the new status of the note and it's default value is NO_OPERATION. +export const updateNoteStatus = + (withTimeOut = true, status = NOTE_STATUS.NO_OPERATION) => + (dispatch, getState) => { + setTimeout(() => { + dispatch(setNoteStatus(status)) + }, withTimeOut ? 1000 : 0) + } diff --git a/app/actions/note.js b/app/actions/note.js new file mode 100644 index 0000000..ae82ac3 --- /dev/null +++ b/app/actions/note.js @@ -0,0 +1,109 @@ +import { createAction } from 'redux-actions' +import { ACTIONS } from '../constants/actions' +import { NOTE_STATUS } from '../constants/noteStatus' +import { addNote, fetchNotes, getNote, removeNote } from './../db' +import { convertFromRaw, convertToRaw } from 'draft-js' +import logger from 'electron-log' + +// Used for adding a new note. +export const addNewNote = createAction(ACTIONS.ADD_NOTE) + +// Used for updating noteList with fetched notes from database. +export const updateNoteList = createAction(ACTIONS.UPDATE_NOTE_LIST) + +// Used for setting the index of selected note. +export const setActiveNoteIndex = createAction(ACTIONS.SET_ACTIVE_NOTE_INDEX) + +// Used for updating title of the active note. +export const updateNoteTitle = createAction(ACTIONS.UPDATE_NOTE_TITLE) + +// Used for updating editor's state of the active note. +export const updateActiveNoteState = + createAction(ACTIONS.UPDATE_ACTIVE_NOTE_STATE) + +// Used for updating rev of the active note. +export const updateNoteRev = createAction(ACTIONS.UPDATE_NOTE_REV) + +// Used for updating status of the active note. +export const setNoteStatus = createAction(ACTIONS.SET_NOTE_STATUS) + +// Used for updating status of the active note. +export const loadNoteContent = createAction(ACTIONS.LOAD_NOTE_CONTENT) + +// Used for deleting a note from noteList. +export const deleteNoteFromList = createAction(ACTIONS.DELETE_NOTE_FROM_LIST) + +// Async method, Used for fetching all notes from database. +export const fetchAllNotes = () => (dispatch, getState) => { + fetchNotes().then((result) => { + // Update only if there are notes. + if (result.total_rows > 0) { + dispatch(updateNoteList(result.rows)) + } + }).catch((err) => { + logger.error('Unable to fetch notes ' + err) + }) +} + +// Helper method, Used for setting noteStatus to NOTE_SAVE_FAIL. +const _noteSaveFailed = (dispatch, err = null) => { + dispatch(setNoteStatus(NOTE_STATUS.NOTE_SAVE_FAIL)) + logger.error('Unable to save note ' + err) +} + +// Async method, Used for saving note (new or update) to database and updates +// noteStatus. +export const saveNote = () => (dispatch, getState) => { + dispatch(setNoteStatus(NOTE_STATUS.SAVING_NOTE)) + const state = getState().noteReducer + const noteIndex = state.activeNoteIndex + const doc = { + _id: state.notes[noteIndex].id, + _rev: state.notes[noteIndex].rev, + title: state.notes[noteIndex].title, + content: convertToRaw(state.activeNoteState.getCurrentContent()) + } + addNote(doc).then((result) => { + if (result.ok) { + // Update note rev to avoid conflicts while the next time for saving + // this note. + dispatch(updateNoteRev(result.rev)) + + dispatch(setNoteStatus(NOTE_STATUS.NOTE_SAVE_SUCCESS)) + } else { + _noteSaveFailed(dispatch) + } + }).catch((err) => _noteSaveFailed(dispatch, err)) +} + +// Async method, Used for getting active note content from database and loads it. +export const fetchNote = () => (dispatch, getState) => { + setNoteStatus(NOTE_STATUS.LOADING_NOTE) + + const state = getState().noteReducer + const noteIndex = state.activeNoteIndex + getNote(state.notes[noteIndex].id).then((result) => { + dispatch(loadNoteContent(convertFromRaw(result.content))) + dispatch(setNoteStatus(NOTE_STATUS.NOTE_LOAD_SUCCESS)) + }).catch((err) => { + dispatch(setNoteStatus(NOTE_STATUS.NOTE_LOAD_FAIL)) + logger.error('Unable to get note ' + err) + }) +} + +// Async method, Used for removing active note from database. +export const deleteNote = () => (dispatch, getState) => { + setNoteStatus(NOTE_STATUS.DELETING_NOTE) + + const state = getState().noteReducer + const noteIndex = state.activeNoteIndex + removeNote(state.notes[noteIndex].id, + state.notes[noteIndex].rev).then((result) => { + dispatch(deleteNoteFromList(noteIndex)) + dispatch(setActiveNoteIndex(ACTIONS.NOT_SELECTED_NOTE)) + dispatch(setNoteStatus(NOTE_STATUS.NOTE_DELETE_SUCCESS)) + }).catch((err) => { + dispatch(setNoteStatus(NOTE_STATUS.NOTE_DELETE_FAIL)) + logger.error('Unable to remove note ' + err) + }) +} diff --git a/app/assets/styles/index.css b/app/assets/styles/index.css index 2da9207..0c5cd8f 100644 --- a/app/assets/styles/index.css +++ b/app/assets/styles/index.css @@ -49,7 +49,51 @@ .middle-page { @extend .font-style-base; - position: fixed; - left: 35%; + position: sticky; + justify-content: center; + display: flex; top: 45%; } +.home-container { + padding: 0px; + margin-left: -3px; +} +.home-sidebar { + position: sticky; + top: 48px; + height: calc(100vh - 48px); + flex: 0 1 320px; + -webkit-box-flex: 0; + border-right: 1px solid rgba(0, 0, 0, 0.125); + overflow: auto; + padding: 0px!important; +} + +.home-content { + padding: 0px; +} + +.NoteEditor { + font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace; + font-size: 15px; + font-weight: bold; + color: black; + line-height: 30px; + cursor: text; + background: linear-gradient(transparent, transparent 28px, green 30px); + background-size: 30px 30px; + min-height: calc(100vh - 48px); + border-left: 3px solid #D44147; + padding: 5px; + padding-left: 10px; +} + +.NoteEditor-hidePlaceholder { + display: none; +} + +.noteItem { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} diff --git a/app/components/App.js b/app/components/App.js index b5eb409..2cd7092 100644 --- a/app/components/App.js +++ b/app/components/App.js @@ -3,19 +3,17 @@ import { Switch, Route, BrowserRouter, Redirect } from 'react-router-dom' import routes from '../routes' export default () => ( -
- - - {routes.map((route, index) => ( - - ))} - - - -
+ + + {routes.map((route, index) => ( + + ))} + + + ) diff --git a/app/components/Header.js b/app/components/Header.js new file mode 100644 index 0000000..63a4567 --- /dev/null +++ b/app/components/Header.js @@ -0,0 +1,81 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { ACTIONS } from '../constants/actions' + +export default class Header extends Component { + constructor (props) { + // Initialize this using super + super() + + this.onAddNote = this.onAddNote.bind(this) + this.onSaveNote = this.onSaveNote.bind(this) + this.onDeleteNote = this.onDeleteNote.bind(this) + } + + onAddNote () { + // Do not proceed as button is disabled + if (this.props.isNewNoteDisabled) return + + this.props.setNewNoteDisabled(true) + this.props.addNewNote() + } + + onSaveNote () { + // Do not proceed as button is disabled + if (this.props.isSaveDisabled) return + + this.props.toggleYesNoModal(ACTIONS.SAVE_NOTE) + } + + onDeleteNote () { + // Do not proceed as button is disabled + if (this.props.isDeleteDisabled) return + + this.props.toggleYesNoModal(ACTIONS.DELETE_NOTE) + } + + render () { + let newNoteBtnClass = 'btn btn-outline-success btn-sm' + if (this.props.isNewNoteDisabled) { newNoteBtnClass += ' disabled' } + + let saveBtnClass = 'ml-2 btn btn-outline-primary btn-sm' + if (this.props.isSaveDisabled) { saveBtnClass += ' disabled' } + + let deleteBtnClass = 'ml-2 btn btn-outline-danger btn-sm' + if (this.props.isDeleteDisabled) { deleteBtnClass += ' disabled' } + + return ( + + ) + } +} + +Header.propTypes = { + addNewNote: PropTypes.func.isRequired, + setNewNoteDisabled: PropTypes.func.isRequired, + isNewNoteDisabled: PropTypes.bool.isRequired, + isSaveDisabled: PropTypes.bool.isRequired, + isDeleteDisabled: PropTypes.bool.isRequired, + toggleYesNoModal: PropTypes.func.isRequired +} diff --git a/app/components/Home.js b/app/components/Home.js index eb77f21..28f15af 100644 --- a/app/components/Home.js +++ b/app/components/Home.js @@ -1,6 +1,36 @@ import React from 'react' -export default () => ( -
-

Deer

-
-) +import { Redirect } from 'react-router-dom' +import Header from './../containers/Header' +import NoteList from './../containers/NoteList' +import HomeContent from './../containers/HomeContent' +import YesNoModal from './../containers/YesNoModal' +import StatusModal from './../containers/StatusModal' +import { + checkRedirectToWelcomePage, + setNotFirstTimeFlag +} from '../../utils/api.electron' + +export default () => { + if (checkRedirectToWelcomePage()) { + setNotFirstTimeFlag() + return ( + + ) + } + + return ( +
+
+
+
+ +
+
+ +
+
+ + +
+ ) +} diff --git a/app/components/HomeContent.js b/app/components/HomeContent.js new file mode 100644 index 0000000..b7a5855 --- /dev/null +++ b/app/components/HomeContent.js @@ -0,0 +1,20 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import NoteEditor from './../containers/NoteEditor' + +export default class HomeContent extends Component { + render () { + // Show homeContent when no note is selected. + if (this.props.activeNoteIndex >= 0) { return () } + + return ( +
+

Please select a note or add a new one

+
+ ) + } +} + +HomeContent.propTypes = { + activeNoteIndex: PropTypes.number.isRequired +} diff --git a/app/components/NoteEditor.js b/app/components/NoteEditor.js new file mode 100644 index 0000000..3b114ff --- /dev/null +++ b/app/components/NoteEditor.js @@ -0,0 +1,72 @@ +import React, { Component } from 'react' +import { Editor, EditorState } from 'draft-js' +import PropTypes from 'prop-types' + +export default class NoteEditor extends Component { + constructor (props) { + // Initialize this using super + super() + + this.onEditorChange = this.onEditorChange.bind(this) + } + + onEditorChange (newEditorState) { + const currentContent = this.props.activeNoteState.getCurrentContent() + const newContent = newEditorState.getCurrentContent() + if (currentContent !== newContent) { + // We only save first 40 characters of the first non-empty line if there + // is a change. + const currentText = + currentContent.getPlainText().trim().split('\u000A')[0].substring(0, 40) + const newText = + newContent.getPlainText().trim().split('\u000A')[0].substring(0, 40) + if (currentText !== newText) { this.props.updateNoteTitle(newText) } + + // There is a change in content + if (newContent.hasText() && newText.trim()) { + // The new content has text, so we will enable save button. + this.props.setSaveDisabled(false) + } else { + // The new content is empty, so we will disable new Note and save + // buttons. + this.props.setNewNoteDisabled(true) + this.props.setSaveDisabled(true) + } + } + + this.props.updateActiveNoteState(newEditorState) + } + + componentDidMount () { + // Enable delete button as editor is mounted. + this.props.setDeleteDisabled(false) + } + + componentWillUnmount () { + // Disable save and delete buttons as editor will be unmounted. + this.props.setSaveDisabled(true) + this.props.setDeleteDisabled(true) + this.props.updateActiveNoteState(EditorState.createEmpty()) + } + + render () { + return ( +
+ +
+ ) + } +} + +NoteEditor.propTypes = { + activeNoteState: PropTypes.object.isRequired, + setSaveDisabled: PropTypes.func.isRequired, + setDeleteDisabled: PropTypes.func.isRequired, + setNewNoteDisabled: PropTypes.func.isRequired, + updateActiveNoteState: PropTypes.func.isRequired, + updateNoteTitle: PropTypes.func.isRequired +} diff --git a/app/components/NoteItem.js b/app/components/NoteItem.js new file mode 100644 index 0000000..77126df --- /dev/null +++ b/app/components/NoteItem.js @@ -0,0 +1,35 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class NoteItem extends Component { + constructor (props) { + // Initialize this using super + super() + + this.onNoteClick = this.onNoteClick.bind(this) + } + + onNoteClick () { + this.props.select(this.props.index) + } + + render () { + const title = this.props.value || 'Empty note' + + let classNames = 'list-group-item list-group-item-action noteItem' + if (this.props.isActive) { classNames += ' active text-white' } + + return ( + + {title} + + ) + } +} + +NoteItem.propTypes = { + index: PropTypes.number.isRequired, + value: PropTypes.string.isRequired, + select: PropTypes.func.isRequired, + isActive: PropTypes.bool.isRequired +} diff --git a/app/components/NoteList.js b/app/components/NoteList.js new file mode 100644 index 0000000..7a6f211 --- /dev/null +++ b/app/components/NoteList.js @@ -0,0 +1,48 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import NoteItem from './NoteItem' + +export default class NoteList extends Component { + constructor (props) { + // Initialize this using super + super() + + this.onSelect = this.onSelect.bind(this) + } + + componentDidMount () { + // Trigger fetching notes as this component is loaded. + this.props.fetchAllNotes() + } + + onSelect (noteIndex = -1) { + // Do nothing as it's already selected. + if (this.props.activeNoteIndex === noteIndex) { return } + + this.props.setActiveNoteIndex(noteIndex) + this.props.fetchNote(noteIndex) + } + + render () { + return ( +
+ {this.props.notes.map((note, index) => ( + + ))} +
+ ) + } +} + +NoteList.propTypes = { + activeNoteIndex: PropTypes.number.isRequired, + notes: PropTypes.array.isRequired, + fetchAllNotes: PropTypes.func.isRequired, + fetchNote: PropTypes.func.isRequired, + setActiveNoteIndex: PropTypes.func.isRequired +} diff --git a/app/components/StatusModal.js b/app/components/StatusModal.js new file mode 100644 index 0000000..aa63014 --- /dev/null +++ b/app/components/StatusModal.js @@ -0,0 +1,127 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Modal, ModalBody, ModalFooter } from 'reactstrap' +import { NOTE_STATUS } from '../constants/noteStatus' + +export default class StatusModal extends Component { + constructor (props) { + // Initialize this using super + super() + + this._getModalConfig = this._getModalConfig.bind(this) + this.onSaveFail = this.onSaveFail.bind(this) + this.onLoadFail = this.onLoadFail.bind(this) + this.onDeleteFail = this.onDeleteFail.bind(this) + } + + componentDidUpdate () { + switch (this.props.noteStatus) { + case NOTE_STATUS.NOTE_SAVE_SUCCESS: + this.props.setSaveDisabled(true) + this.props.setNewNoteDisabled(false) + this.props.updateNoteStatus() + break + case NOTE_STATUS.NOTE_LOAD_SUCCESS: + case NOTE_STATUS.NOTE_DELETE_SUCCESS: + this.props.updateNoteStatus() + break + default: + break + } + } + + // Called when saving note is failed. + onSaveFail () { + this.props.setSaveDisabled(false) + this.props.updateNoteStatus(false) + } + + // Called when loading note is failed. + onLoadFail () { + this.props.updateNoteStatus(false) + } + + onDeleteFail () { + this.props.setDeleteDisabled(false) + this.props.updateNoteStatus(false) + } + + _getModalConfig () { + // This modal is shown when any operation happens. + let showModal = + this.props.noteStatus !== NOTE_STATUS.NO_OPERATION + + let modalBody = '' + let callBack = () => {} + let showFooter = false + switch (this.props.noteStatus) { + case NOTE_STATUS.SAVING_NOTE: + modalBody = 'Saving note...' + break + case NOTE_STATUS.NOTE_SAVE_SUCCESS: + modalBody = 'Note saved successfully.' + break + case NOTE_STATUS.NOTE_SAVE_FAIL: + modalBody = 'Unable to save note.' + showFooter = true + callBack = this.onSaveFail + break + case NOTE_STATUS.LOADING_NOTE: + modalBody = 'Loading note...' + break + case NOTE_STATUS.NOTE_LOAD_SUCCESS: + modalBody = 'Note loaded successfully.' + break + case NOTE_STATUS.NOTE_LOAD_FAIL: + modalBody = 'Unable to load note.' + showFooter = true + callBack = this.onLoadFail + break + case NOTE_STATUS.DELETING_NOTE: + modalBody = 'Deleting note...' + break + case NOTE_STATUS.NOTE_DELETE_SUCCESS: + modalBody = 'Note deleted successfully.' + break + case NOTE_STATUS.NOTE_DELETE_FAIL: + modalBody = 'Unable to delete note.' + showFooter = true + callBack = this.onDeleteFail + break + default: + break + } + return { + showModal: showModal, + modalBody: modalBody, + callBack: callBack, + showFooter: showFooter + } + } + + render () { + const modalConfig = this._getModalConfig() + return ( + + + {modalConfig.modalBody} + + {modalConfig.showFooter ? + + : ''} + + ) + } +} + +StatusModal.propTypes = { + noteStatus: PropTypes.string.isRequired, + updateNoteStatus: PropTypes.func.isRequired, + setSaveDisabled: PropTypes.func.isRequired, + setNewNoteDisabled: PropTypes.func.isRequired, + setDeleteDisabled: PropTypes.func.isRequired +} diff --git a/app/components/YesNoModal.js b/app/components/YesNoModal.js new file mode 100644 index 0000000..74eac88 --- /dev/null +++ b/app/components/YesNoModal.js @@ -0,0 +1,83 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Modal, ModalBody, ModalFooter } from 'reactstrap' +import { ACTIONS } from '../constants/actions' + +export default class YesNoModal extends Component { + constructor (props) { + // Initialize this using super + super() + + this._getModalConfig = this._getModalConfig.bind(this) + this.onSaveNote = this.onSaveNote.bind(this) + this.onDeleteNote = this.onDeleteNote.bind(this) + } + + // Called when saving is confirmed. + onSaveNote () { + this.props.setSaveDisabled(true) + this.props.toggleYesNoModal() + this.props.saveNote() + } + + // Called when deleting is confirmed. + onDeleteNote () { + this.props.setDeleteDisabled(true) + this.props.toggleYesNoModal() + this.props.deleteNote() + } + + _getModalConfig () { + let modalBody = '' + let callBack = () => {} + switch (this.props.yesNoAction) { + case ACTIONS.SAVE_NOTE: + modalBody = 'Do you want to save this note?' + callBack = this.onSaveNote + break + case ACTIONS.DELETE_NOTE: + modalBody = 'Do you want to delete this note?' + callBack = this.onDeleteNote + break + default: + break + } + return { + modalBody: modalBody, + callBack: callBack + } + } + + render () { + const modalConfig = this._getModalConfig() + return ( + + + {modalConfig.modalBody} + + + + + + + ) + } +} + +YesNoModal.propTypes = { + yesNoAction: PropTypes.string.isRequired, + showYesNoModal: PropTypes.bool.isRequired, + toggleYesNoModal: PropTypes.func.isRequired, + setSaveDisabled: PropTypes.func.isRequired, + setDeleteDisabled: PropTypes.func.isRequired, + saveNote: PropTypes.func.isRequired, + deleteNote: PropTypes.func.isRequired +} diff --git a/app/constants/actions.js b/app/constants/actions.js index 0712304..d0356a6 100644 --- a/app/constants/actions.js +++ b/app/constants/actions.js @@ -1,4 +1,22 @@ export const ACTIONS = { GET_NEXT_LANG: 'SET_NEXT_LANG', - TOGGLE_FADE: 'TOGGLE_FADE' + TOGGLE_FADE: 'TOGGLE_FADE', + FETCH_ALL_NOTES: 'FETCH_ALL_NOTES', + UPDATE_NOTE_LIST: 'UPDATE_NOTE_LIST', + SET_ACTIVE_NOTE_INDEX: 'SET_ACTIVE_NOTE', + UPDATE_NEW_NOTE_STATE: 'UPDATE_NEW_NOTE_STATE', + UPDATE_SAVE_NOTE_STATE: 'UPDATE_SAVE_NOTE_STATE', + ADD_NOTE: 'ADD_NOTE', + UPDATE_NOTE_TITLE: 'UPDATE_NOTE_TITLE', + UPDATE_ACTIVE_NOTE_STATE: 'UPDATE_ACTIVE_NOTE_STATE', + TOGGLE_SAVE_MODAL: 'TOGGLE_SAVE_MODAL', + SET_NOTE_STATUS: 'SET_NOTE_STATUS', + UPDATE_NOTE_REV: 'UPDATE_NOTE_REV', + LOAD_NOTE_CONTENT: 'LOAD_NOTE_CONTENT', + DELETE_NOTE_FROM_LIST: 'DELETE_NOTE_FROM_LIST', + SAVE_NOTE: 'SAVE_NOTE', + DELETE_NOTE: 'DELETE_NOTE', + UPDATE_DELETE_NOTE_STATE: 'UPDATE_DELETE_NOTE_STATE', + NO_ACTION: 'NO_ACTION', + NOT_SELECTED_NOTE: -1 } diff --git a/app/constants/noteStatus.js b/app/constants/noteStatus.js new file mode 100644 index 0000000..03cda01 --- /dev/null +++ b/app/constants/noteStatus.js @@ -0,0 +1,12 @@ +export const NOTE_STATUS = { + NO_OPERATION: 'NO_OPERATION', + SAVING_NOTE: 'SAVING_NOTE', + NOTE_SAVE_SUCCESS: 'NOTE_SAVE_SUCCESS', + NOTE_SAVE_FAIL: 'NOTE_SAVE_FAIL', + LOADING_NOTE: 'LOADING_NOTE', + NOTE_LOAD_SUCCESS: 'NOTE_LOAD_SUCCESS', + NOTE_LOAD_FAIL: 'NOTE_LOAD_FAIL', + DELETING_NOTE: 'DELETING_NOTE', + NOTE_DELETE_SUCCESS: 'NOTE_DELETE_SUCCESS', + NOTE_DELETE_FAIL: 'NOTE_DELETE_FAIL' +} diff --git a/app/containers/Header.js b/app/containers/Header.js new file mode 100644 index 0000000..74922cc --- /dev/null +++ b/app/containers/Header.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux' +import Header from '../components/Header' +import { + setNewNoteDisabled +} from '../actions/header' +import { + addNewNote +} from '../actions/note' +import { + toggleYesNoModal +} from '../actions/modal' + +const mapStateToProps = state => ({ + isNewNoteDisabled: state.headerReducer.isNewNoteDisabled, + isSaveDisabled: state.headerReducer.isSaveDisabled, + isDeleteDisabled: state.headerReducer.isDeleteDisabled +}) + +const mapDispatchToProps = dispatch => ({ + addNewNote: () => dispatch(addNewNote()), + setNewNoteDisabled: (flag) => dispatch(setNewNoteDisabled(flag)), + toggleYesNoModal: (action) => dispatch(toggleYesNoModal(action)) +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Header) diff --git a/app/containers/HomeContent.js b/app/containers/HomeContent.js new file mode 100644 index 0000000..4c86a82 --- /dev/null +++ b/app/containers/HomeContent.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux' +import HomeContent from '../components/HomeContent' + +const mapStateToProps = state => ({ + activeNoteIndex: state.noteReducer.activeNoteIndex +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(HomeContent) diff --git a/app/containers/NoteEditor.js b/app/containers/NoteEditor.js new file mode 100644 index 0000000..8f19153 --- /dev/null +++ b/app/containers/NoteEditor.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux' +import NoteEditor from '../components/NoteEditor' +import { + setSaveDisabled, + setNewNoteDisabled, + setDeleteDisabled +} from '../actions/header' +import { + updateNoteTitle, + updateActiveNoteState +} from '../actions/note' + +const mapStateToProps = state => ({ + activeNoteState: state.noteReducer.activeNoteState +}) + +const mapDispatchToProps = dispatch => ({ + updateActiveNoteState: (state) => dispatch(updateActiveNoteState(state)), + setSaveDisabled: (flag) => dispatch(setSaveDisabled(flag)), + setDeleteDisabled: (flag) => dispatch(setDeleteDisabled(flag)), + setNewNoteDisabled: (flag) => dispatch(setNewNoteDisabled(flag)), + updateNoteTitle: (content) => dispatch(updateNoteTitle(content)) +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(NoteEditor) diff --git a/app/containers/NoteList.js b/app/containers/NoteList.js new file mode 100644 index 0000000..325118e --- /dev/null +++ b/app/containers/NoteList.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux' +import NoteList from '../components/NoteList' +import { + fetchAllNotes, + setActiveNoteIndex, + fetchNote +} from '../actions/note' + +const mapStateToProps = state => ({ + activeNoteIndex: state.noteReducer.activeNoteIndex, + notes: state.noteReducer.notes +}) + +const mapDispatchToProps = dispatch => ({ + fetchAllNotes: () => dispatch(fetchAllNotes()), + fetchNote: () => dispatch(fetchNote()), + setActiveNoteIndex: (noteIndex) => dispatch(setActiveNoteIndex(noteIndex)) +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(NoteList) diff --git a/app/containers/StatusModal.js b/app/containers/StatusModal.js new file mode 100644 index 0000000..3637957 --- /dev/null +++ b/app/containers/StatusModal.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux' +import StatusModal from '../components/StatusModal' +import { + updateNoteStatus +} from '../actions/modal' +import { + setSaveDisabled, + setNewNoteDisabled, + setDeleteDisabled +} from '../actions/header' + +const mapStateToProps = state => ({ + noteStatus: state.noteReducer.noteStatus +}) + +const mapDispatchToProps = dispatch => ({ + updateNoteStatus: (withTimeOut, status) => + dispatch(updateNoteStatus(withTimeOut, status)), + setSaveDisabled: (flag) => dispatch(setSaveDisabled(flag)), + setNewNoteDisabled: (flag) => dispatch(setNewNoteDisabled(flag)), + setDeleteDisabled: (flag) => dispatch(setDeleteDisabled(flag)) +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(StatusModal) diff --git a/app/containers/YesNoModal.js b/app/containers/YesNoModal.js new file mode 100644 index 0000000..c77b9ff --- /dev/null +++ b/app/containers/YesNoModal.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux' +import YesNoModal from '../components/YesNoModal' +import { + toggleYesNoModal +} from '../actions/modal' +import { + setSaveDisabled, + setDeleteDisabled +} from '../actions/header' +import { + saveNote, + deleteNote +} from '../actions/note' + +const mapStateToProps = state => ({ + showYesNoModal: state.modalReducer.showYesNoModal, + yesNoAction: state.modalReducer.yesNoAction +}) + +const mapDispatchToProps = dispatch => ({ + toggleYesNoModal: (action) => dispatch(toggleYesNoModal(action)), + setSaveDisabled: (flag) => dispatch(setSaveDisabled(flag)), + setDeleteDisabled: (flag) => dispatch(setDeleteDisabled(flag)), + saveNote: () => dispatch(saveNote()), + deleteNote: () => dispatch(deleteNote()) +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(YesNoModal) diff --git a/app/db.js b/app/db.js index b6b1166..977982c 100644 --- a/app/db.js +++ b/app/db.js @@ -11,7 +11,17 @@ export const fetchNotes = () => { }) } -// Adds new note to database then fetches +// Adds / Updates a note to database. export const addNote = (doc) => { return notesDB.put(doc) } + +// Gets a note from database using its ID. +export const getNote = (noteId) => { + return notesDB.get(noteId) +} + +// Deletes a note from database using its ID and rev. +export const removeNote = (noteId, noteRev) => { + return notesDB.remove(noteId, noteRev) +} diff --git a/app/reducers/header.js b/app/reducers/header.js new file mode 100644 index 0000000..bb68099 --- /dev/null +++ b/app/reducers/header.js @@ -0,0 +1,30 @@ +import { ACTIONS } from '../constants/actions' + +const INITIAL_STATE = { + isNewNoteDisabled: false, + isSaveDisabled: true, + isDeleteDisabled: true +} + +const _updateNoteState = (state, field, newValue) => { + // Just return the state as there is no change in isNewNoteDisabled flag. + if (state[field] === newValue) { return state } + + return { + ...state, + [field]: newValue + } +} + +export default (state = INITIAL_STATE, action) => { + switch (action.type) { + case ACTIONS.UPDATE_NEW_NOTE_STATE: + return _updateNoteState(state, 'isNewNoteDisabled', action.payload) + case ACTIONS.UPDATE_SAVE_NOTE_STATE: + return _updateNoteState(state, 'isSaveDisabled', action.payload) + case ACTIONS.UPDATE_DELETE_NOTE_STATE: + return _updateNoteState(state, 'isDeleteDisabled', action.payload) + default: + return state + } +} diff --git a/app/reducers/index.js b/app/reducers/index.js index e44c74f..390f255 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -1,8 +1,14 @@ import { combineReducers } from 'redux' import welcome from './welcome' +import note from './note' +import header from './header' +import modal from './modal' // Creates a single root reducer out of Deer's reducers and returns an object // whose values are reducing functions with keys passed. export default combineReducers({ - welcomeReducer: welcome + welcomeReducer: welcome, + noteReducer: note, + headerReducer: header, + modalReducer: modal }) diff --git a/app/reducers/modal.js b/app/reducers/modal.js new file mode 100644 index 0000000..0069922 --- /dev/null +++ b/app/reducers/modal.js @@ -0,0 +1,24 @@ +import { ACTIONS } from '../constants/actions' + +const INITIAL_STATE = { + showYesNoModal: false, + yesNoAction: ACTIONS.NO_ACTION +} + +export default (state = INITIAL_STATE, action) => { + switch (action.type) { + case ACTIONS.TOGGLE_SAVE_MODAL: + let noteAction = state.yesNoAction + if (!state.showYesNoModal && action.payload && + ACTIONS.hasOwnProperty(action.payload)) { + noteAction = action.payload + } + return { + ...state, + showYesNoModal: !state.showYesNoModal, + yesNoAction: noteAction + } + default: + return state + } +} diff --git a/app/reducers/note.js b/app/reducers/note.js new file mode 100644 index 0000000..ac3bfae --- /dev/null +++ b/app/reducers/note.js @@ -0,0 +1,103 @@ +import uuidv4 from 'uuid/v4' +import { EditorState } from 'draft-js' +import { ACTIONS } from '../constants/actions' +import { NOTE_STATUS } from '../constants/noteStatus' +import logger from 'electron-log' + +const INITIAL_STATE = { + activeNoteIndex: ACTIONS.NOT_SELECTED_NOTE, + activeNoteState: EditorState.createEmpty(), + noteStatus: NOTE_STATUS.NO_OPERATION, + notes: [] +} + +// Helper method, Updates a field with a newValue of an element in notes array +// without altering provided state if there is a change in value, otherwise +// returns provided state. +const _updateNoteEntry = (state, field, newValue) => { + // Just return state as there is no active note. + if (state.activeNoteIndex < 0 || + state.activeNoteIndex >= state.notes.length) { + logger.warn('action is fired with out of range' + + ' activeNoteIndex') + return state + } + + const currentNote = Object.create(state.notes[state.activeNoteIndex]) + + // Just return the state if there is not a change. + if (currentNote[field] === newValue) { return state } + + currentNote[field] = newValue + return { + ...state, + notes: [ + ...state.notes.slice(0, state.activeNoteIndex), + currentNote, + ...state.notes.slice(state.activeNoteIndex + 1) + ] + } +} + +export default (state = INITIAL_STATE, action) => { + switch (action.type) { + case ACTIONS.UPDATE_NOTE_LIST: + return { + ...state, + notes: action.payload.map(note => { + return { + id: note.doc._id, + rev: note.doc._rev, + title: note.doc.title + } + }) + } + case ACTIONS.SET_ACTIVE_NOTE_INDEX: + return { + ...state, + activeNoteIndex: action.payload + } + case ACTIONS.ADD_NOTE: + return { + ...state, + notes: [...state.notes, { + id: uuidv4(), + rev: '', + title: '' + }], + activeNoteIndex: state.notes.length, + activeNoteState: EditorState.createEmpty() + } + case ACTIONS.UPDATE_NOTE_TITLE: + return _updateNoteEntry(state, 'title', action.payload) + case ACTIONS.UPDATE_NOTE_REV: + return _updateNoteEntry(state, 'rev', action.payload) + case ACTIONS.UPDATE_ACTIVE_NOTE_STATE: + return { + ...state, + activeNoteState: action.payload + } + case ACTIONS.SET_NOTE_STATUS: + if (!NOTE_STATUS.hasOwnProperty(action.payload)) { + logger.warn('Trying to set unsupported noteStatus: ' + action.payload) + return state + } + + return { + ...state, + noteStatus: action.payload + } + case ACTIONS.LOAD_NOTE_CONTENT: + return { + ...state, + activeNoteState: EditorState.createWithContent(action.payload) + } + case ACTIONS.DELETE_NOTE_FROM_LIST: + return { + ...state, + notes: state.notes.filter(note => note.id === action.payload) + } + default: + return state + } +} diff --git a/appveyor.yml b/appveyor.yml index 6c81905..661799a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -17,6 +17,7 @@ install: test_script: - npm run lint - npm run build + - npm run test # Don't actually build. build: off diff --git a/docs/List-of-dependencies.md b/docs/List-of-dependencies.md index af4c61e..241882f 100644 --- a/docs/List-of-dependencies.md +++ b/docs/List-of-dependencies.md @@ -9,22 +9,27 @@ Packages which are used during development or which are used to build Deer's bun | babel-plugin-transform-object-rest-spread | ^6.26.0 | A Babel plugin responsible for transforming rest and spread properties for objects | | babel-preset-env | ^1.7.0 | A Babel plugin responsible for compiling Javascript ES6 code down to ES5 | | babel-preset-react | ^6.24.1 | A Babel plugin responsible for compiling JSX down to Javascript | -| css-loader | ^0.28.11 | A Webpack loader responsible for collecting CSS from all the css files and put it into a string | +| css-loader | ^1.0.0 | A Webpack loader responsible for collecting CSS from all the css files and put it into a string | | electron | ^2.0.2 | Build cross platform desktop apps | | electron-builder | ^20.15.1 | Package and build a ready for distribution Electron app | | electron-reload | ^1.2.2 | Reload contents of when the source files are changed | -| eslint | ^4.19.1 | Linting utility for JavaScript and JSX | -| eslint-config-standard | ^11.0.0 | JavaScript Standard Style | -| eslint-config-standard-react | ^6.0.0 | JavaScript Standard Style React/JSX support | +| enzyme | ^3.6.0 | JavaScript Testing utilities for React | +| enzyme-adapter-react-16 | ^1.5.0 | JavaScript Testing utilities for React | +| enzyme-to-json | ^3.3.4 | Convert enzyme wrapper to a format compatible with Jest snapshot | +| eslint | ^5.4.0 | Linting utility for JavaScript and JSX | +| eslint-config-standard | ^12.0.0 | JavaScript Standard Style | +| eslint-config-standard-react | ^7.0.2 | JavaScript Standard Style React/JSX support | | eslint-plugin-import | ^2.12.0 | Support linting of ES2015+ (ES6+) import/export syntax | -| eslint-plugin-node | ^6.0.1 | ESLint's rules for Node.js | -| eslint-plugin-promise | ^3.8.0 | Enforce best practices for JavaScript promises | +| eslint-plugin-node | ^7.0.1 | ESLint's rules for Node.js | +| eslint-plugin-promise | ^4.0.0 | Enforce best practices for JavaScript promises | | eslint-plugin-react | ^7.9.1 | React specific linting rules for ESLint | -| eslint-plugin-standard | ^3.1.0 | ESlint Plugin for the Standard Linter | -| file-loader | ^1.1.11 | A Webpack loader responsible for emitting files that will be bundled and returning their public URLs (e.g. Images) | +| eslint-plugin-standard | ^4.0.0 | ESlint Plugin for the Standard Linter | +| file-loader | ^2.0.0 | A Webpack loader responsible for emitting files that will be bundled and returning their public URLs (e.g. Images) | | husky | ^0.14.3 | Prevent bad commit, push by doing checks before it takes place | +| jest | ^23.6.0 | Delightful JavaScript Testing | | jquery | ^3.3.1 | Needed for electron-builder packages | -| style-loader | ^0.21.0 | A Webpack loader responsibile for taking the output string generated by by css-loader and put it inside the