From fa1f1e9f1926e8a06d8b98d585f0ef4208cf6d14 Mon Sep 17 00:00:00 2001 From: Jason Wall Date: Sun, 9 Aug 2020 09:44:15 -0400 Subject: [PATCH] start the reducer spec --- app/db/src/app/services/app-state-actions.ts | 91 +++++ .../app/services/app-state-initial-state.ts | 59 +++ .../app/services/app-state-reducer.spec.ts | 89 +++++ app/db/src/app/services/app-state-reducer.ts | 375 ++++++++++++++++++ 4 files changed, 614 insertions(+) create mode 100644 app/db/src/app/services/app-state-actions.ts create mode 100644 app/db/src/app/services/app-state-initial-state.ts create mode 100644 app/db/src/app/services/app-state-reducer.spec.ts create mode 100644 app/db/src/app/services/app-state-reducer.ts diff --git a/app/db/src/app/services/app-state-actions.ts b/app/db/src/app/services/app-state-actions.ts new file mode 100644 index 00000000..01f59dcc --- /dev/null +++ b/app/db/src/app/services/app-state-actions.ts @@ -0,0 +1,91 @@ +import { SavedPage, Error, CardItem, DisplaySettings, User } from '../models/app-state'; +import { IStorable } from '../models/storable'; +import { NoteItem } from '../models/note-state'; +import { ListDirection } from '../common/list-direction'; + +export type AppAction = + | { + type: 'GET_SAVED_PAGE'; + pageId: string; + } + | { + type: 'SAVE_PAGE'; + title: string; + } + | { + type: 'UPDATE_CURRENT_PAGE'; + } + | { + type: 'UPDATE_SAVED_PAGES'; + savedPages: IStorable; + } + | { + type: 'ADD_CARD_TO_SAVED_PAGE'; + card: CardItem; + pageId: string; + } + | { + type: 'ADD_CARD'; + card: CardItem; + nextToItem: CardItem; + } + | { + type: 'UPDATE_CARD'; + newCard: CardItem; + oldCard: CardItem; + } + | { + type: 'REMOVE_CARD'; + card: CardItem; + } + | { + type: 'MOVE_CARD'; + card: CardItem; + direction: ListDirection; + } + | { + type: 'UPDATE_ERROR'; + error: Error; + } + | { + type: 'UPDATE_FONT_SIZE'; + cardFontSize: number; + } + | { + type: 'UPDATE_FONT_FAMILY'; + cardFontFamily: string; + } + | { + type: 'UPDATE_AUTOCOMPLETE'; + words: string[]; + } + | { + type: 'UPDATE_DISPLAY_SETTINGS'; + settings: IStorable; + } + | { + type: 'SET_USER'; + user: User; + } + | { + type: 'FIND_NOTES'; + qry: string; + nextToItem: CardItem; + } + | { + type: 'GET_NOTE'; + noteId: string; + nextToItem: CardItem; + } + | { + type: 'UPDATE_NOTES'; + notes: IStorable; + } + | { + type: 'SAVE_NOTE'; + note: NoteItem; + } + | { + type: 'DELETE_NOTE'; + note: NoteItem; + }; diff --git a/app/db/src/app/services/app-state-initial-state.ts b/app/db/src/app/services/app-state-initial-state.ts new file mode 100644 index 00000000..c44d6be2 --- /dev/null +++ b/app/db/src/app/services/app-state-initial-state.ts @@ -0,0 +1,59 @@ +import { UUID } from 'angular2-uuid'; + +import { AppState } from '../models/app-state'; +import { PageTitles, PageIcons } from '../constants'; + +export const initialState: AppState = { + user: null, + cards: [ + { + qry: '', + dict: 'n/a', + type: 'Note', + data: { + id: UUID.UUID(), + xref: '1 pe 2:16; jn 3:16', + title: 'Title Here', + content: '# Content Here\nIn Markdown format.', + }, + }, + ], + autocomplete: [], + currentSavedPage: null, + savedPages: { + createdOn: new Date(0).toISOString(), + value: [], + }, + notes: { + createdOn: new Date(0).toISOString(), + value: [], + }, + savedPagesLoaded: false, + mainPages: [ + { title: PageTitles.Search, icon: PageIcons.Search, route: 'search' }, + { title: PageTitles.Help, icon: PageIcons.Help, route: 'help' }, + ], + error: null, + displaySettings: { + createdOn: new Date(0).toISOString(), + value: { + showStrongsAsModal: false, + appendCardToBottom: true, + insertCardNextToItem: true, + clearSearchAfterQuery: true, + cardFontSize: 12, + cardFontFamily: 'PT Serif', + showVersesOnNewLine: false, + showVerseNumbers: false, + showParagraphs: true, + showParagraphHeadings: true, + syncCardsAcrossDevices: false, + }, + }, + cardIcons: { + words: 'font_download', + passage: 'menu_book', + strongs: 'speaker_notes', + note: 'text_snippet', + }, +}; diff --git a/app/db/src/app/services/app-state-reducer.spec.ts b/app/db/src/app/services/app-state-reducer.spec.ts new file mode 100644 index 00000000..24f6ec00 --- /dev/null +++ b/app/db/src/app/services/app-state-reducer.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from '@angular/core/testing'; +import { reducer } from './app-state-reducer'; +import { initialState } from './app-state-initial-state'; +import { UUID } from 'angular2-uuid'; +import { PageTitles, PageIcons } from '../constants'; +import { AppAction } from './app-state-actions'; + +describe('AppService Reducer', () => { + const preState = { + user: null, + cards: [ + { + qry: '', + dict: 'n/a', + type: 'Note', + data: { + id: UUID.UUID(), + xref: '1 pe 2:16; jn 3:16', + title: 'Title Here', + content: '# Content Here\nIn Markdown format.', + }, + }, + ], + autocomplete: [], + currentSavedPage: null, + savedPages: { + createdOn: new Date(0).toISOString(), + value: [], + }, + notes: { + createdOn: new Date(0).toISOString(), + value: [], + }, + savedPagesLoaded: false, + mainPages: [ + { title: PageTitles.Search, icon: PageIcons.Search, route: 'search' }, + { title: PageTitles.Help, icon: PageIcons.Help, route: 'help' }, + ], + error: null, + displaySettings: { + createdOn: new Date(0).toISOString(), + value: { + showStrongsAsModal: false, + appendCardToBottom: true, + insertCardNextToItem: true, + clearSearchAfterQuery: true, + cardFontSize: 12, + cardFontFamily: 'PT Serif', + showVersesOnNewLine: false, + showVerseNumbers: false, + showParagraphs: true, + showParagraphHeadings: true, + syncCardsAcrossDevices: false, + }, + }, + cardIcons: { + words: 'font_download', + passage: 'menu_book', + strongs: 'speaker_notes', + note: 'text_snippet', + }, + }; + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('UPDATE_FONT_SIZE', () => { + const action = { + type: 'UPDATE_FONT_SIZE', + cardFontSize: 32, + } as AppAction; + + const testState = reducer(preState, action); + + expect(testState.displaySettings.value.cardFontSize).toBe(32); + }); + + it('UPDATE_FONT_FAMILY', () => { + const action = { + type: 'UPDATE_FONT_FAMILY', + cardFontFamily: 'Jason', + } as AppAction; + + const testState = reducer(preState, action); + + expect(testState.displaySettings.value.cardFontFamily).toBe('Jason'); + }); +}); diff --git a/app/db/src/app/services/app-state-reducer.ts b/app/db/src/app/services/app-state-reducer.ts new file mode 100644 index 00000000..76047188 --- /dev/null +++ b/app/db/src/app/services/app-state-reducer.ts @@ -0,0 +1,375 @@ +import { UUID } from 'angular2-uuid'; + +import { AppState, SavedPage, DisplaySettings } from '../models/app-state'; +import { IStorable, Storable } from '../models/storable'; +import { NoteItem } from '../models/note-state'; + +import { ListDirection } from '../common/list-direction'; + +import { AppAction } from './app-state-actions'; +import { initialState } from './app-state-initial-state'; + +export function reducer(state: AppState, action: AppAction): AppState { + // somtimes the state is null. lets not complain if that happens. + if (state === undefined) { + state = initialState; + } + + switch (action.type) { + //#region Display Settings + + case 'UPDATE_DISPLAY_SETTINGS': { + return maybeMutateStorable(state, action.settings, state.displaySettings, (item) => { + return { + ...state, + displaySettings: item, + }; + }); + } + case 'UPDATE_FONT_SIZE': { + const settings = new Storable({ + ...state.displaySettings.value, + cardFontSize: action.cardFontSize, + }); + + return reducer(state, { + type: 'UPDATE_DISPLAY_SETTINGS', + settings, + }); + } + case 'UPDATE_FONT_FAMILY': { + const settings = new Storable({ + ...state.displaySettings.value, + cardFontFamily: action.cardFontFamily, + }); + + return reducer(state, { + type: 'UPDATE_DISPLAY_SETTINGS', + settings, + }); + } + + //#endregion + + case 'UPDATE_AUTOCOMPLETE': { + return { + ...state, + autocomplete: [...action.words], + }; + } + case 'UPDATE_SAVED_PAGES': { + return maybeMutateStorable(state, action.savedPages, state.savedPages, (item) => { + return { + ...state, + savedPagesLoaded: true, + savedPages: item, + }; + }); + } + case 'UPDATE_CURRENT_PAGE': { + const savedPages = new Storable([ + ...state.savedPages.value.filter((o) => o.id !== state.currentSavedPage.id), + { + id: state.currentSavedPage.id, + title: state.currentSavedPage.title, + queries: [...state.cards], + }, + ]); + + return reducer(state, { + type: 'UPDATE_SAVED_PAGES', + savedPages, + }); + } + case 'SAVE_PAGE': { + const savedPages = new Storable([ + ...state.savedPages.value, + { + // create a new saved page object + title: action.title, + id: UUID.UUID().toString(), + queries: [...state.cards], + }, + ]); + + return reducer(state, { + type: 'UPDATE_SAVED_PAGES', + savedPages, + }); + } + case 'GET_SAVED_PAGE': { + const page = state.savedPages.value.find((o) => o.id.toString() === action.pageId); + + if (!page) { + return state; + } + + return { + ...state, + currentSavedPage: page, + cards: [...page.queries], + }; + } + case 'ADD_CARD_TO_SAVED_PAGE': { + const savedPages = new Storable([ + ...state.savedPages.value.map((o) => { + if (o.id.toString() === action.pageId) { + let cards = []; + if (state.displaySettings.value.appendCardToBottom) { + cards = [...o.queries, action.card]; + } else { + cards = [action.card, ...o.queries]; + } + return { + ...o, + queries: cards, + }; + } + return o; + }), + ]); + + return reducer(state, { + type: 'UPDATE_SAVED_PAGES', + savedPages, + }); + } + case 'ADD_CARD': { + let cards = []; + + if (action.nextToItem && state.displaySettings.value.insertCardNextToItem) { + const idx = state.cards.indexOf(action.nextToItem); + + if (state.displaySettings.value.appendCardToBottom) { + const before = state.cards.slice(0, idx + 1); + const after = state.cards.slice(idx + 1); + cards = [...before, action.card, ...after]; + } else { + const before = state.cards.slice(0, idx); + const after = state.cards.slice(idx); + cards = [...before, action.card, ...after]; + } + } else { + if (state.displaySettings.value.appendCardToBottom) { + cards = [...state.cards, action.card]; + } else { + cards = [action.card, ...state.cards]; + } + } + return { + ...state, + cards, + }; + } + case 'UPDATE_CARD': { + return { + ...state, + cards: state.cards.map((c) => { + if (c === action.oldCard) { + return action.newCard; + } + return c; + }), + }; + } + case 'REMOVE_CARD': { + return { + ...state, + cards: [...state.cards.filter((c) => c !== action.card)], + }; + } + case 'MOVE_CARD': { + let cards = []; + const idx = state.cards.indexOf(action.card); + + if ( + (idx === 0 && action.direction === ListDirection.Up) || // can't go up if you're at the top + (idx === state.cards.length - 1 && action.direction === ListDirection.Down) // can't go down if you're at the bottom + ) { + // you can't go up. + return state; + } + + const before = state.cards.slice(0, idx); + const after = state.cards.slice(idx + 1); + + if (action.direction === ListDirection.Down) { + cards = [...before, after[0], action.card, ...after.slice(1)]; + } else { + cards = [...before.slice(0, before.length - 1), action.card, before[before.length - 1], ...after]; + } + + return { + ...state, + cards, + }; + } + case 'SET_USER': { + return { + ...state, + user: action.user, + }; + } + case 'FIND_NOTES': { + const notes = state.notes.value + .filter((o) => o.title.search(action.qry) > -1) + .map((o) => { + return { + qry: o.id, + dict: 'n/a', + type: 'Note', + data: o, + }; + }); + + let cards = []; + + if (action.nextToItem && state.displaySettings.value.insertCardNextToItem) { + const idx = state.cards.indexOf(action.nextToItem); + + if (state.displaySettings.value.appendCardToBottom) { + const before = state.cards.slice(0, idx + 1); + const after = state.cards.slice(idx + 1); + cards = [...before, ...notes, ...after]; + } else { + const before = state.cards.slice(0, idx); + const after = state.cards.slice(idx); + cards = [...before, ...notes, ...after]; + } + } else { + if (state.displaySettings.value.appendCardToBottom) { + cards = [...state.cards, ...notes]; + } else { + cards = [...notes, ...state.cards]; + } + } + return { + ...state, + cards, + }; + } + case 'GET_NOTE': { + const note = state.notes.value.find((o) => o.id === action.noteId); + const card = { + qry: note.id, + dict: 'n/a', + type: 'Note', + data: note, + }; + + return reducer(state, { + type: 'ADD_CARD', + card, + nextToItem: action.nextToItem, + }); + } + case 'UPDATE_NOTES': { + return { + ...state, + notes: action.notes, + }; + } + case 'SAVE_NOTE': { + // you may be creating a new note or updating an existing. + // if its an update, you need to update the note in the following: + // * card list could have it. + // * notes list could have it. + // * it could be in any of the saved pages lists... + // so iterate through all of them and if you find the note + // in any of them, swap it out + + const cards = [ + ...state.cards.map((o) => { + const n = o.data as NoteItem; + if (n && n.id === action.note.id) { + return { + ...o, + data: action.note, + }; + } + return o; + }), + ]; + + const notes = new Storable([ + ...state.notes.value.filter((o) => o.id !== action.note.id), + action.note, + ]); + + const savedPages = new Storable([ + ...state.savedPages.value.map((sp) => { + return { + ...sp, + queries: sp.queries.map((o) => { + const n = o.data as NoteItem; + if (n && n.id === action.note.id) { + return { + ...o, + data: action.note, + }; + } + return o; + }), + }; + }), + ]); + const newState = { + ...state, + cards, + notes, + savedPages, + }; + return newState; + } + case 'DELETE_NOTE': { + // the note may be in any of the following: + // * card list could have it. + // * notes list could have it. + // * it could be in any of the saved pages lists... + // so iterate through all of them and if you find the note + // in any of them, remove it + + const cards = [ + ...state.cards.filter((o) => { + const n = o.data as NoteItem; + return !n || n.id !== action.note.id; + }), + ]; + + const notes = new Storable([...state.notes.value.filter((o) => o.id !== action.note.id)]); + + const savedPages = new Storable([ + ...state.savedPages.value.map((sp) => { + return { + ...sp, + queries: sp.queries.filter((o) => { + const n = o.data as NoteItem; + return !n || n.id !== action.note.id; + }), + }; + }), + ]); + return { + ...state, + cards, + notes, + savedPages, + }; + } + } +} + +function maybeMutateStorable( + state: AppState, + candidate: IStorable, + incumbant: IStorable, + composeState: (item: IStorable) => AppState +): AppState { + // only update if the settings are newer. + if (new Date(candidate.createdOn) > new Date(incumbant.createdOn)) { + return composeState(candidate); + } + + // candidate didn't win. return state untouched. + return state; +}