feature/improved-article-content-loading #179

Merged
danysmall merged 7 commits from feature/improved-article-content-loading into develop 2022-11-12 23:19:40 +00:00
13 changed files with 173 additions and 106 deletions
Showing only changes of commit fecce76fe1 - Show all commits

View File

@ -1,30 +1,43 @@
# Install dependencies only when needed # Install dependencies of project
FROM node:fermium-alpine AS deps FROM node:fermium-alpine AS dependencies
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /home/app/ WORKDIR /home/app/
COPY package.json package-lock.json ./ COPY package.json ./
RUN npm ci RUN npm i
# Bundle static assets with nginx # Build application to bunch of static files
FROM node:fermium-alpine as production FROM node:fermium-alpine AS builder
# Copy built assets from builder
WORKDIR /home/app/ WORKDIR /home/app/
COPY --from=deps ./home/app/node_modules ./node_modules COPY --from=dependencies ./home/app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build
# NGINX image
FROM nginx:1.21.6 as production
# Copy built assets from builder
COPY --from=builder /home/app/build /usr/share/nginx/html
# Add nginx.config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy setup nginx entrypoint file
COPY ./scripts/entrypoint.sh .
# Expose ports # Expose ports
EXPOSE 3000 EXPOSE 80
# Execute script
RUN chmod +x ./entrypoint.sh
ENV NODE_ENV production ENV NODE_ENV production
ENV USER_NAME=node_user USER_UID=2000 GROUP_NAME=node_group GROUP_UID=2000 # ENV USER_NAME=node_user USER_UID=2000 GROUP_NAME=node_group GROUP_UID=2000
RUN deluser --remove-home node \ # RUN deluser --remove-home node \
&& addgroup --g ${GROUP_UID} -S ${GROUP_NAME} \ # && addgroup --g ${GROUP_UID} -S ${GROUP_NAME} \
&& adduser -D -S -s /sbin/nologin -u ${USER_UID} -G ${GROUP_NAME} ${USER_NAME} # && adduser -D -S -s /sbin/nologin -u ${USER_UID} -G ${GROUP_NAME} ${USER_NAME}
USER "${USER_NAME}" # USER "${USER_NAME}"
ENTRYPOINT ["./entrypoint.sh"]
ENTRYPOINT [ "npm","start"] # Start serving
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,6 +1,9 @@
#! /bin/bash #! /bin/bash
# no verbose # no verbose
set +x set +x
# save env to file
printenv > .env.production
# config # config
envFilename='.env.production' envFilename='.env.production'
resolvingPath='/usr/share/nginx/html' resolvingPath='/usr/share/nginx/html'

View File

@ -1,44 +1,30 @@
import type { ArticleStore } from "../domain/articleStore"; import type { ArticleStore } from "../domain/articleStore";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { getArticle } from "article/data/articleAPIService";
import { Article } from "article/domain/articleEntity"; import { Article } from "article/domain/articleEntity";
import { GetArticleUseCase } from "article/useCases/getArticleUseCase";
import { FetchArticleUseCase } from "article/useCases/fetchArticleUseCase";
import { useParams } from "react-router-dom";
function useArticleViewModel( function useArticleViewModel(
store: ArticleStore, store: ArticleStore,
fetchArticleUseCase: ( fetchArticleUseCase: FetchArticleUseCase,
fetchArticleCallback: (id: string) => Promise<Article | null>, getArticleUseCase: GetArticleUseCase,
setArticle: ArticleStore["setArticle"],
id: string,
) => Promise<Article | null>,
getArticleUseCase: (
getArticle: ArticleStore["getArticle"],
setArticle: ArticleStore["setArticle"],
id: Article["id"],
) => Promise<Article | null>,
id: string,
) { ) {
let article: Article | null | undefined; const { id } = useParams();
const _getArticle = useCallback( const getArticle = useCallback(
(id: string) => { () => {
getArticleUseCase(store.getArticle, store.setArticle, id).then(async (value) => { getArticleUseCase.call(id ?? '').catch((_) => fetchArticleUseCase.call(id ?? ''));
if (value == null) { console.log(id);
article = await fetchArticleUseCase(getArticle, store.setArticle, id);
return;
}
article = value;
});
}, },
[store.setArticle] [id]
); );
useEffect(() => { useEffect(getArticle, []);
_getArticle(id);
}, [article]);
return { return {
article: store.article, article: store.currentArticle,
shouldShowLoading: typeof store.article === "undefined" || store.isLoading, shouldShowLoading: store.isLoading,
hasError: store.hasError, hasError: store.hasError,
}; };
} }

View File

@ -7,7 +7,7 @@ import { integratorApiClient } from "core/httpClient";
const articleEndpoint = "/papers/" const articleEndpoint = "/papers/"
async function getArticle(id: string): Promise<Article> { async function fetchArticle(id: string): Promise<Article> {
try { try {
const response = await integratorApiClient.get<FetchArticleByIdDTO>( const response = await integratorApiClient.get<FetchArticleByIdDTO>(
// `https://run.mocky.io/v3/62cd4581-d864-4d46-b1d6-02b45b5d1994/${id}` // `https://run.mocky.io/v3/62cd4581-d864-4d46-b1d6-02b45b5d1994/${id}`
@ -33,4 +33,4 @@ async function getArticle(id: string): Promise<Article> {
} }
} }
export { getArticle }; export { fetchArticle };

View File

@ -1,22 +1,25 @@
import type { Article } from "../domain/articleEntity"; import type { Article } from "../domain/articleEntity";
import { getArticle as getArticleAPI } from "./articleAPIService";
import * as actionTypes from "./articleActionTypes"; import * as actionTypes from "./articleActionTypes";
import { dispatchStatus } from "../../store/index"; import { dispatchStatus } from "../../store/index";
const setArticleAction = (article: Article) => (dispatch: any) => const setArticleAction = (article: Article, articlesList: Array<Article>) => (dispatch: any) => {
dispatch({ type: actionTypes.SET_ARTICLE, article }); if (!articlesList.includes(article)) {
const updatedList = articlesList.concat([article]);
dispatch({ type: actionTypes.SET_ARTICLE, article, updatedList });
}
}
const getArticleAction = (id: string) => (dispatch: any) => { const getArticleAction = (id: string, articles: Array<Article>) => (dispatch: any) => {
const filteredArray = articles.filter((e) => e.id == id);
if (filteredArray.length > 0) {
const article = filteredArray[0];
dispatchStatus(actionTypes.GET_ARTICLE, ".success", article)(dispatch);
return article;
}
return getArticleAPI(id) const reason = 'Article not in the store';
.then((article) => { dispatchStatus(actionTypes.GET_ARTICLE, ".failure", reason)(dispatch);
dispatchStatus(actionTypes.GET_ARTICLE, ".success", article)(dispatch); return null;
return article;
})
.catch((reason) => {
dispatchStatus(actionTypes.GET_ARTICLE, ".failure", reason)(dispatch);
return reason;
});
}; };
export { setArticleAction, getArticleAction }; export { setArticleAction, getArticleAction };

View File

@ -2,37 +2,58 @@ import React, { useCallback, useState } from "react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { ArticleStore } from "../domain/articleStore"; import { ArticleStore } from "../domain/articleStore";
import type { Article } from "../domain/articleEntity"; import type { Article } from "../domain/articleEntity";
import { getArticle as getArticleAPI } from "./articleAPIService";
const useArticleCommonStore = (): ArticleStore => { const useArticleCommonStore = (): ArticleStore => {
const [isLoading, setLoading] = useState<boolean>(false); const [isLoading, setLoading] = useState<boolean>(false);
const [hasError, setError] = useState<boolean>(false); const [hasError, setError] = useState<boolean>(false);
const [article, setArticleState] = useState<Article | undefined>(); const [currentArticle, setCurrentArticle] = useState<Article | null>(null);
const [articles, setArticlesState] = useState<Array<Article>>([]);
const dispatch = useDispatch(); const dispatch = useDispatch();
const getArticle = useCallback( const getArticle = useCallback(
async (id: string) => { (id: string) => {
setLoading(true); setLoading(true);
try { if (typeof currentArticle === undefined) {
const article = await getArticleAPI(id); const fromStore = findArticleFromStore(id);
setArticleState(article); if (typeof fromStore === undefined) {
setError(true);
return null;
}
setLoading(false); setLoading(false);
return article; setCurrentArticle(fromStore);
} catch (error) { return fromStore;
setError(true);
return null;
} }
setLoading(false);
return currentArticle;
}, },
[dispatch] [dispatch]
); );
const findArticleFromStore = (id: string): Article | null => {
const filteredArray = articles.filter((e) => e.id == id);
if (filteredArray.length > 0) {
return filteredArray[0];
}
return null;
}
const setNewArticle = (newArticle: Article) => {
setCurrentArticle(newArticle);
if (!articles.includes(newArticle)) {
setArticlesState(articles.concat([newArticle]));
} else if (articles.length == 0) {
setArticlesState([newArticle]);
}
}
return { return {
article: article, articles: articles,
currentArticle: currentArticle,
isLoading, isLoading,
hasError, hasError,
setArticle: setArticleState, setArticle: setNewArticle,
getArticle, getArticle: getArticle,
}; };
}; };

View File

@ -5,7 +5,8 @@ import * as actionTypes from "./articleActionTypes";
type ArticleStoreState = Omit<ArticleStore, "getArticle" | "setArticle">; type ArticleStoreState = Omit<ArticleStore, "getArticle" | "setArticle">;
const INITIAL_STATE: ArticleStoreState = { const INITIAL_STATE: ArticleStoreState = {
article: undefined, articles: [],
currentArticle: null,
isLoading: false, isLoading: false,
hasError: false, hasError: false,
}; };
@ -16,11 +17,11 @@ const articleReducer = (
): ArticleStoreState => { ): ArticleStoreState => {
switch (action.type) { switch (action.type) {
case actionTypes.SET_ARTICLE: case actionTypes.SET_ARTICLE:
return { ...state, article: action.article }; return { ...state, articles: action.updatedList, currentArticle: action.article };
case actionTypes.GET_ARTICLE: case actionTypes.GET_ARTICLE:
return { ...state, isLoading: true }; return { ...state, isLoading: true };
case actionTypes.GET_ARTICLE_SUCCESS: case actionTypes.GET_ARTICLE_SUCCESS:
return { ...state, isLoading: false, article: action.payload }; return { ...state, isLoading: false, currentArticle: action.payload };
case actionTypes.GET_ARTICLE_FAILURE: case actionTypes.GET_ARTICLE_FAILURE:
return { ...state, hasError: true, isLoading: false }; return { ...state, hasError: true, isLoading: false };
default: default:

View File

@ -9,22 +9,23 @@ import { RootState, useAppSelector } from "store";
const articleSelector = (state: RootState): ArticleStoreState => state.article; const articleSelector = (state: RootState): ArticleStoreState => state.article;
const useArticleStore = (): ArticleStore => { const useArticleStore = (): ArticleStore => {
const { isLoading, article, hasError } = useAppSelector(articleSelector); const { isLoading, hasError, currentArticle, articles } = useAppSelector(articleSelector);
const dispatch = useDispatch(); const dispatch = useDispatch();
const setArticle = useCallback( const setArticle = useCallback(
(article: Article) => setArticleAction(article)(dispatch), (article: Article) => setArticleAction(article, articles)(dispatch),
[dispatch] [dispatch]
); );
const getArticle = useCallback( const getArticle = useCallback(
(id: string) => getArticleAction(id)(dispatch), (id: string) => getArticleAction(id, articles)(dispatch),
[dispatch] [dispatch]
); );
return { return {
article: article, articles: articles,
currentArticle: currentArticle,
isLoading, isLoading,
hasError, hasError,
setArticle, setArticle,

View File

@ -1,13 +1,14 @@
import { Article } from './articleEntity'; import { Article } from './articleEntity';
interface ArticleStore { interface ArticleStore {
// State // State
article: Article | undefined; articles: Array<Article>;
currentArticle: Article | null;
isLoading: boolean; isLoading: boolean;
hasError: boolean; hasError: boolean;
// Actions // Actions
setArticle(article?: Article): void; setArticle(article: Article): void;
getArticle(identifier: string): Promise<Article | null>; getArticle(identifier: string): Article | null;
} }
export type { ArticleStore }; export type { ArticleStore };

View File

@ -1,6 +1,32 @@
import { Article } from "article/domain/articleEntity"; import { Article } from "article/domain/articleEntity";
import { ArticleStore } from "article/domain/articleStore"; import { ArticleStore } from "article/domain/articleStore";
class FetchArticleUseCase {
/* ------------------------------ Dependencies ------------------------------ */
_fetchArticleCallback: (id: string) => Promise<Article | null>;
_store: ArticleStore;
/* -------------------------------------------------------------------------- */
constructor(
fetchArticle: (id: string) => Promise<Article | null>,
store: ArticleStore,
) {
this._fetchArticleCallback = fetchArticle;
this._store = store;
}
/* ----------------------------- Implementation ----------------------------- */
async call(id: string): Promise<Article | null> {
return this._fetchArticleCallback(id).then((article) => {
if (article != null) {
this._store.setArticle(article);
}
return article;
})
}
/* -------------------------------------------------------------------------- */
}
const fetchArticleUseCase = async ( const fetchArticleUseCase = async (
fetchArticleCallback: (id: string) => Promise<Article | null>, fetchArticleCallback: (id: string) => Promise<Article | null>,
setArticle: ArticleStore["setArticle"], setArticle: ArticleStore["setArticle"],
@ -12,5 +38,5 @@ const fetchArticleUseCase = async (
} }
return article; return article;
}; };
export { FetchArticleUseCase };
export { fetchArticleUseCase }; export { fetchArticleUseCase };

View File

@ -1,6 +1,28 @@
import { Article } from "article/domain/articleEntity"; import { Article } from "article/domain/articleEntity";
import type { ArticleStore } from "../domain/articleStore"; import type { ArticleStore } from "../domain/articleStore";
class GetArticleUseCase {
/* ------------------------------ Dependencies ------------------------------ */
_store: ArticleStore;
/* -------------------------------------------------------------------------- */
constructor(
store: ArticleStore,
) {
this._store = store;
}
/* ----------------------------- Implementation ----------------------------- */
async call(id: string): Promise<Article> {
const storedArticle = this._store.getArticle(id);
if (storedArticle != null) {
this._store.setArticle(storedArticle);
return storedArticle;
}
throw new Error('Article not found');
}
/* -------------------------------------------------------------------------- */
}
const getArticleUseCase = async ( const getArticleUseCase = async (
getArticle: ArticleStore["getArticle"], getArticle: ArticleStore["getArticle"],
setArticle: ArticleStore["setArticle"], setArticle: ArticleStore["setArticle"],
@ -13,4 +35,5 @@ const getArticleUseCase = async (
return article; return article;
}; };
export { GetArticleUseCase };
export { getArticleUseCase }; export { getArticleUseCase };

View File

@ -10,27 +10,20 @@ import { SVGSearch } from "components/icons";
import BaseLayout from "components/BaseLayout"; import BaseLayout from "components/BaseLayout";
import Typography from "components/typography/Typography"; import Typography from "components/typography/Typography";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { fetchArticleUseCase } from "article/useCases/fetchArticleUseCase"; import { FetchArticleUseCase } from "article/useCases/fetchArticleUseCase";
import { getArticleUseCase } from "article/useCases/getArticleUseCase"; import { GetArticleUseCase } from "article/useCases/getArticleUseCase";
import { fetchArticle } from "article/data/articleAPIService";
const AnArticle = () => { const AnArticle = () => {
const store = useArticleStore(); const store = useArticleStore();
const { id } = useParams();
const { article, hasError, shouldShowLoading } = useArticleViewModel( const { article, hasError, shouldShowLoading } = useArticleViewModel(
store, store,
fetchArticleUseCase, new FetchArticleUseCase(fetchArticle, store),
getArticleUseCase, new GetArticleUseCase(store),
id ?? '',
); );
const { i18n, t } = useTranslation(); const { i18n, t } = useTranslation();
// const { id } = useParams();
// const newId = `${id}`;
// useEffect(() => {
// store.getArticle(newId);
// }, [id]);
if (hasError) { if (hasError) {
return <NotFound />; return <NotFound />;
} }

View File

@ -13,21 +13,17 @@ import BaseLayout from "components/BaseLayout";
import Container from "components/Container"; import Container from "components/Container";
import NotFound from "./NotFound"; import NotFound from "./NotFound";
import Markdown from "components/Markdown"; import Markdown from "components/Markdown";
import { fetchArticleUseCase } from "article/useCases/fetchArticleUseCase"; import { FetchArticleUseCase } from "article/useCases/fetchArticleUseCase";
import { getArticleUseCase } from "article/useCases/getArticleUseCase"; import { GetArticleUseCase } from "article/useCases/getArticleUseCase";
import { fetchArticle } from "article/data/articleAPIService";
const AnArticleBody = () => { const AnArticleBody = () => {
const store = useArticleStore(); const store = useArticleStore();
const { id } = useParams();
const { article, hasError, shouldShowLoading } = useArticleViewModel( const { article, hasError, shouldShowLoading } = useArticleViewModel(
store, store,
fetchArticleUseCase, new FetchArticleUseCase(fetchArticle, store),
getArticleUseCase, new GetArticleUseCase(store),
id ?? '',
); );
// useEffect(() => {
// store.getArticle(newId);
// }, [id]);
if (hasError) <NotFound />; if (hasError) <NotFound />;
return ( return (
<BaseLayout> <BaseLayout>