Commit 049e91e8 authored by Julien Enselme's avatar Julien Enselme

feat: switch to immer to manage RSS backends actions

State management is made easier thanks to the switch.
parent a4f6d118
......@@ -12,6 +12,7 @@ import {
import {Subscription as RxjsSubscription} from 'rxjs';
import {pluck} from 'rxjs/operators';
import runtime from 'serviceworker-webpack-plugin/lib/runtime';
import {setAutoFreeze} from 'immer';
import * as constants from './constants';
import {AuthorizeStep} from './services/authorize';
import {
......@@ -21,6 +22,9 @@ import {rehydratePartiallyFromLocalStorage} from './store/middlewares';
import {fetchArticles, fetchCategories, registerRssBackendsActions} from './store/rss-backends-actions';
import {State} from './store/state';
// This is required to allow Aurelia to add its observer on objects in the state.
setAutoFreeze(false);
@autoinject()
export class App {
public burgerMenuExpanded: boolean = false;
......
......@@ -3,8 +3,8 @@ export interface Article {
readonly feedId: number;
readonly feedTitle: string;
readonly id: number;
readonly isRead: boolean;
readonly isFavorite: boolean;
isRead: boolean;
isFavorite: boolean;
readonly link: string;
readonly title: string;
readonly updatedAt: Date;
......
import {produce} from 'immer';
import {Container} from 'aurelia-framework';
import * as LogManager from 'aurelia-logging';
import {Store} from 'aurelia-store';
......@@ -13,11 +14,9 @@ import {Auth, SelectableBackend, State} from './state';
const logger = LogManager.getLogger('aurss:store:actions:rss');
export function selectBackend(state: State, selectedBackend: SelectableBackend) {
const newState = {...state};
newState.selectedBackend = selectedBackend;
return newState;
}
export const selectBackend = produce((state: State, selectedBackend: SelectableBackend) => {
state.selectedBackend = selectedBackend;
});
/**
* This action is meant to be called when the authenticate method of the backend succeeds.
......@@ -37,66 +36,46 @@ export function authenticateSucceeded(state: State, authInfos: Auth) {
* This action is meant to be called when the isLoggedIn method of the backend returns true.
* @param state
*/
export function isLoggedIn(state: State) {
const newState = {...state};
newState.authentication = {...state.authentication};
newState.authentication.isLoggedIn = true;
return newState;
}
export const isLoggedIn = produce((state: State) => {
state.authentication.isLoggedIn = true;
});
export function fetchArticles(state: State, categoryId: string) {
const newState = {...state};
newState.rss = {...state.rss};
newState.rss.isFetching = true;
export const fetchArticles = produce((state: State, categoryId: string) => {
state.rss.isFetching = true;
getBackend(state.selectedBackend).getArticles(categoryId);
});
return newState;
}
export function receivedArticles(state: State, articles: Article[]) {
const newState = {...state};
newState.rss = {...state.rss};
newState.rss.isFetching = false;
newState.rss.displayedArticles = articles;
return newState;
}
export const receivedArticles = produce((state: State, articles: Article[]) => {
state.rss.isFetching = false;
state.rss.displayedArticles = articles;
});
export function fetchCategories(state: State) {
getBackend(state.selectedBackend).getCategories();
return state;
}
export function receivedCategories(state: State, categories: Category[]) {
const newState = {...state};
newState.rss = {...state.rss};
newState.rss.categories = categories;
return newState;
}
export const receivedCategories = produce((state: State, categories: Category[]) => {
state.rss.categories = categories;
});
export function fetchCounters(state: State) {
getBackend(state.selectedBackend).getCounters();
return state;
}
export function receivedCounters(state: State, counters: Counter[]) {
const newState = {...state};
newState.rss = {...state.rss};
export const receivedCounters = produce((state: State, counters: Counter[]) => {
const categoryIdToCounters = counters.reduce((acc, counter) => {
acc[counter.categoryId] = counter.unreadCount;
return acc;
}, {});
newState.rss.categories = state.rss.categories.map((category) => ({
state.rss.categories = state.rss.categories.map((category) => ({
...category,
unreadCount: categoryIdToCounters[category.id],
}));
return newState;
}
});
/**
* Do a request to the backend to mark an article as read.
......@@ -104,20 +83,15 @@ export function receivedCounters(state: State, counters: Counter[]) {
* @param article
* @param discard: If this is true, the article will be added to articlesToDiscards.
*/
export function markAsRead(state: State, article: Article, { discard = false } = {}) {
export const markAsRead = produce((state: State, article: Article, { discard = false } = {}) => {
getBackend(state.selectedBackend).markAsRead(article);
if (!discard) {
return state;
return;
}
const newState = {...state};
newState.rss = {...newState.rss};
newState.rss.articlesToDiscards = [
...newState.rss.articlesToDiscards,
article,
];
return newState;
}
state.rss.articlesToDiscards.push(article);
});
export function markAsUnread(state: State, article: Article) {
getBackend(state.selectedBackend).markAsUnread(article);
......@@ -134,66 +108,54 @@ export function unmarkAsFavorite(state: State, article: Article) {
return state;
}
export function markedAsRead(state: State, article: Article) {
export const markedAsRead = produce((state: State, article: Article) => {
fetchCounters(state);
if (state.rss.articlesToDiscards.includes(article)) {
return removeArticle(state, article);
removeArticle(state.rss.displayedArticles, article);
return;
}
return updateArticle(state, article, { isRead: true });
}
function removeArticle(state: State, article: Article) {
if (!state.rss.displayedArticles.includes(article)) {
return state;
const articleToUpdate = findArticleInArray(state.rss.displayedArticles, article);
if (articleToUpdate) {
articleToUpdate.isRead = true;
}
});
const newState = {...state};
newState.rss = {...state.rss};
newState.rss.displayedArticles = [...newState.rss.displayedArticles];
const indexToRemove = newState.rss.displayedArticles.indexOf(article);
newState.rss.displayedArticles.splice(indexToRemove, 1);
return newState;
}
function updateArticle(state: State, article: Article, patch) {
const newState = {...state};
newState.rss = {...newState.rss};
newState.rss.displayedArticles = updateArticleInArray(
article, newState.rss.displayedArticles, patch,
);
return newState;
}
function updateArticleInArray(article: Article, array: Article[], patch): Article[] {
if (!array.includes(article)) {
return array;
function removeArticle(articles: Article[], article: Article) {
const articleToRemove = findArticleInArray(articles, article);
if (articleToRemove) {
const indexToRemove = articles.indexOf(articleToRemove);
articles.splice(indexToRemove, 1);
}
}
const newArray = [...array];
const indexUpdatedArticle = newArray.indexOf(article);
newArray[indexUpdatedArticle] = {
...newArray[indexUpdatedArticle],
...patch,
};
return newArray;
function findArticleInArray(articles: Article[], article: Article): Article | undefined {
return articles.find(elt => elt.id === article.id);
}
export function markedAsUnread(state: State, article: Article) {
export const markedAsUnread = produce((state: State, article: Article) => {
fetchCounters(state);
return updateArticle(state, article, { isRead: false });
}
export function markedAsFavorite(state: State, article: Article) {
return updateArticle(state, article, { isFavorite: true });
}
const articleToUpdate = findArticleInArray(state.rss.displayedArticles, article);
if (articleToUpdate) {
articleToUpdate.isRead = false;
}
});
export function unmarkedAsFavorite(state: State, article: Article) {
return updateArticle(state, article, { isFavorite: false });
}
export const markedAsFavorite = produce((state: State, article: Article) => {
const articleToUpdate = findArticleInArray(state.rss.displayedArticles, article);
if (articleToUpdate) {
articleToUpdate.isFavorite = true;
}
});
export const unmarkedAsFavorite = produce((state: State, article: Article) => {
const articleToUpdate = findArticleInArray(state.rss.displayedArticles, article);
if (articleToUpdate) {
articleToUpdate.isFavorite = false;
}
});
export function openArticle(state: State, article: Article) {
const tab = window.open(article.link, '_blank');
......@@ -220,75 +182,75 @@ export function getBackend(selectedBackend: SelectableBackend): Backend {
// these actions must be registered.
export function registerRssBackendsActions(store: Store<State>) {
store.registerAction(
selectBackend.name,
'selectBackend',
selectBackend,
);
store.registerAction(
authenticateSucceeded.name,
'authenticateSucceeded',
authenticateSucceeded,
);
store.registerAction(
isLoggedIn.name,
'isLoggedIn',
isLoggedIn,
);
store.registerAction(
markAsRead.name,
'markAsRead',
markAsRead,
);
store.registerAction(
markAsUnread.name,
'markAsUnread',
markAsUnread,
);
store.registerAction(
markedAsRead.name,
'markedAsRead',
markedAsRead,
);
store.registerAction(
markAsFavorite.name,
'markAsFavorite',
markAsFavorite,
);
store.registerAction(
unmarkAsFavorite.name,
'unmarkAsFavorite',
unmarkAsFavorite,
);
store.registerAction(
markedAsUnread.name,
'markedAsUnread',
markedAsUnread,
);
store.registerAction(
markedAsFavorite.name,
'markedAsFavorite',
markedAsFavorite,
);
store.registerAction(
unmarkedAsFavorite.name,
'unmarkedAsFavorite',
unmarkedAsFavorite,
);
store.registerAction(
openArticle.name,
'openArticle',
openArticle,
);
store.registerAction(
fetchArticles.name,
'fetchArticles',
fetchArticles,
);
store.registerAction(
receivedArticles.name,
'receivedArticles',
receivedArticles,
);
store.registerAction(
fetchCategories.name,
'fetchCategories',
fetchCategories,
);
store.registerAction(
receivedCategories.name,
'receivedCategories',
receivedCategories,
);
store.registerAction(
fetchCounters.name,
'fetchCounters',
fetchCounters,
);
store.registerAction(
receivedCounters.name,
'receivedCounters',
receivedCounters,
);
}
......@@ -5,6 +5,9 @@ import 'aurelia-polyfills';
import * as IntlPolyfill from 'intl';
import {GlobalWithFetchMock} from 'jest-fetch-mock';
import * as path from 'path';
import {setAutoFreeze} from 'immer';
setAutoFreeze(false);
/* eslint-disable @typescript-eslint/no-explicit-any */
......
......@@ -47,8 +47,7 @@ describe('rss-backend-actions', () => {
it('markAsRead and discard', () => {
const article = createUnreadArticle();
state.rss.displayedArticles.push(article);
state.rss.articlesToDiscards.push(article);
markAsRead(state, article, { discard: true });
const newState = markedAsRead(state, article);
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment