diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 52a3449..771cbb4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2132,6 +2132,17 @@ "proto-list": "~1.2.1" } }, + "connect": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.6.6.tgz", + "integrity": "sha1-Ce/2xVr3I24TcTWnJXSFi2eG9SQ=", + "requires": { + "debug": "2.6.9", + "finalhandler": "1.1.0", + "parseurl": "~1.3.2", + "utils-merge": "1.0.1" + } + }, "console-browserify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", @@ -2478,6 +2489,14 @@ "node-addon-api": "^1.6.0" } }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -2732,8 +2751,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", - "dev": true + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, "electron-to-chromium": { "version": "1.3.96", @@ -2759,8 +2777,7 @@ "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", - "dev": true + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, "encoding": { "version": "0.1.12", @@ -2812,8 +2829,7 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", - "dev": true + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, "escape-string-regexp": { "version": "1.0.5", @@ -3124,6 +3140,27 @@ } } }, + "finalhandler": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.0.tgz", + "integrity": "sha1-zgtoVbRYU+eRsvzGgARtiCU91/U=", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.1", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.3.1", + "unpipe": "~1.0.0" + }, + "dependencies": { + "statuses": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", + "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=" + } + } + }, "find-up": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", @@ -3210,8 +3247,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -3232,14 +3268,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3254,20 +3288,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -3384,8 +3415,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -3397,7 +3427,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3412,7 +3441,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3420,14 +3448,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -3446,7 +3472,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -3527,8 +3552,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -3540,7 +3564,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -3626,8 +3649,7 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -3663,7 +3685,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3683,7 +3704,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3727,14 +3747,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -5419,8 +5437,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "nan": { "version": "2.12.1", @@ -5809,7 +5826,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", - "dev": true, "requires": { "ee-first": "1.1.1" } @@ -6002,8 +6018,7 @@ "parseurl": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", - "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=", - "dev": true + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" }, "pascalcase": { "version": "0.1.1", @@ -7190,6 +7205,14 @@ "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=", "dev": true }, + "rea": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/rea/-/rea-0.0.1.tgz", + "integrity": "sha1-EO+mFMyQc1Ib2mdLb8lQsDFb16Q=", + "requires": { + "connect": ">=1.4.1" + } + }, "react": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/react/-/react-16.7.0.tgz", @@ -8657,6 +8680,11 @@ "integrity": "sha1-/+3ks2slKQaW5uFl1KWe25mOawI=", "dev": true }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, "unquote": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", @@ -8781,6 +8809,11 @@ "object.getownpropertydescriptors": "^2.0.3" } }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, "uuid": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 40fe194..93055b1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "dependencies": { "@blueprintjs/core": "^3.10.0", "@blueprintjs/icons": "^3.4.0", + "rea": "0.0.1", "react": "^16.7.0", "react-dom": "^16.7.0", "react-redux": "^6.0.0", diff --git a/frontend/src/Documents/List.tsx b/frontend/src/Documents/List.tsx new file mode 100644 index 0000000..444f13c --- /dev/null +++ b/frontend/src/Documents/List.tsx @@ -0,0 +1,5 @@ +import * as React from "react"; + +export function List() { + return
; +} diff --git a/frontend/src/Documents/Overview.tsx b/frontend/src/Documents/Overview.tsx new file mode 100644 index 0000000..8c39515 --- /dev/null +++ b/frontend/src/Documents/Overview.tsx @@ -0,0 +1,62 @@ +import { H1 } from "@blueprintjs/core"; +import * as React from "react"; +import { connect } from "react-redux"; +import { Dispatch } from "redux"; +import { IDocumentJSON } from "~../../src/entity/Document"; +import { fetchDocsStart } from "~redux/docs/actions"; +import { IAppState } from "~redux/reducers"; + +export interface IOverviewComponentProps { + recent: IDocumentJSON[] | null; + all: IDocumentJSON[] | null; + fetching: boolean; + + fetchDocs: () => void; +} + +export class OverviewComponent extends React.PureComponent< + IOverviewComponentProps, + null +> { + constructor(props: IOverviewComponentProps) { + super(props); + } + + public componentDidMount() { + if (!this.props.all) { + this.props.fetchDocs(); + } + } + + public render() { + let docsList; + if (this.props.all) { + docsList = this.props.all.map(doc => ( +
+

{doc.name}

+

{doc.content}

+
+ )); + } + return docsList ||
Loading
; + } +} + +function mapStateToProps(state: IAppState) { + return { + recent: state.docs.recent, + all: state.docs.all, + fetching: state.docs.fetching, + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + fetchDocs: () => dispatch(fetchDocsStart()), + }; +} + +export const Overview = connect( + mapStateToProps, + mapDispatchToProps, +)(OverviewComponent); diff --git a/frontend/src/Home/Home.tsx b/frontend/src/Home/Home.tsx index 6f7b2bf..1ee152e 100644 --- a/frontend/src/Home/Home.tsx +++ b/frontend/src/Home/Home.tsx @@ -8,9 +8,10 @@ import { } from "@blueprintjs/core"; import * as React from "react"; import { connect } from "react-redux"; -import { RouteComponentProps, withRouter } from "react-router"; +import { Route, RouteComponentProps, Switch, withRouter } from "react-router"; import { Dispatch } from "redux"; import { IUserJSON } from "~../../src/entity/User"; +import { Overview } from "~Documents/Overview"; import { IAppState } from "~redux/reducers"; import { logoutUser } from "~redux/user/actions"; @@ -70,7 +71,11 @@ export class HomeComponent extends React.PureComponent { /> -
+
+ + + +
) ); diff --git a/frontend/src/redux/api/docs/index.ts b/frontend/src/redux/api/docs/index.ts new file mode 100644 index 0000000..6dac8c7 --- /dev/null +++ b/frontend/src/redux/api/docs/index.ts @@ -0,0 +1,14 @@ +import { IDocumentJSON } from "~../../src/entity/Document"; +import { IAPIResponse } from "~../../src/types"; + +import { fetchJSONAuth } from "../utils"; + +export async function fetchRecentDocs(): Promise< + IAPIResponse +> { + return fetchJSONAuth("/docs/list/recent", "GET"); +} + +export async function fetchAllDocs(): Promise> { + return fetchJSONAuth("/docs/list", "GET"); +} diff --git a/frontend/src/redux/docs/actions.ts b/frontend/src/redux/docs/actions.ts new file mode 100644 index 0000000..a347450 --- /dev/null +++ b/frontend/src/redux/docs/actions.ts @@ -0,0 +1,47 @@ +import { Action } from "redux"; +import { IDocumentJSON } from "~../../src/entity/Document"; + +export enum DocsTypes { + DOCS_FETCH_START = "DOCS_FETCH_START", + DOCS_FETCH_FAIL = "DOCS_FETCH_FAIL", + DOCS_FETCH_SUCCESS = "DOCS_FETCH_SUCCESS", +} + +export interface IDocsFetchStartAction extends Action { + type: DocsTypes.DOCS_FETCH_START; +} + +export interface IDocsFetchFailAction extends Action { + type: DocsTypes.DOCS_FETCH_FAIL; + payload: { + error: string; + }; +} + +export interface IDocsFetchSuccessAction extends Action { + type: DocsTypes.DOCS_FETCH_SUCCESS; + payload: { + recent: IDocumentJSON[]; + all: IDocumentJSON[]; + }; +} + +export function fetchDocsStart(): IDocsFetchStartAction { + return { type: DocsTypes.DOCS_FETCH_START }; +} + +export function fetchDocsFail(error: string): IDocsFetchFailAction { + return { type: DocsTypes.DOCS_FETCH_FAIL, payload: { error } }; +} + +export function fetchDocsSuccess( + recent: IDocumentJSON[], + all: IDocumentJSON[], +): IDocsFetchSuccessAction { + return { type: DocsTypes.DOCS_FETCH_SUCCESS, payload: { recent, all } }; +} + +export type DocsAction = + | IDocsFetchStartAction + | IDocsFetchFailAction + | IDocsFetchSuccessAction; diff --git a/frontend/src/redux/docs/reducer.ts b/frontend/src/redux/docs/reducer.ts new file mode 100644 index 0000000..d4d7ce8 --- /dev/null +++ b/frontend/src/redux/docs/reducer.ts @@ -0,0 +1,35 @@ +import { Reducer } from "react"; +import { IDocumentJSON } from "~../../src/entity/Document"; + +import { DocsAction, DocsTypes } from "./actions"; + +export interface IDocsState { + recent: IDocumentJSON[] | null; + all: IDocumentJSON[] | null; + fetching: boolean; + error: string | null; +} + +const defaultDocsState: IDocsState = { + recent: null, + all: null, + fetching: false, + error: null, +}; + +export const docsReducer: Reducer = ( + state: IDocsState = defaultDocsState, + action: DocsAction, +) => { + switch (action.type) { + case DocsTypes.DOCS_FETCH_START: + return { ...defaultDocsState, fetching: true }; + case DocsTypes.DOCS_FETCH_SUCCESS: + return { ...defaultDocsState, ...action.payload }; + case DocsTypes.DOCS_FETCH_FAIL: + return { ...defaultDocsState, ...action.payload }; + default: + return state; + break; + } +}; diff --git a/frontend/src/redux/docs/sagas.ts b/frontend/src/redux/docs/sagas.ts new file mode 100644 index 0000000..2d698dd --- /dev/null +++ b/frontend/src/redux/docs/sagas.ts @@ -0,0 +1,56 @@ +import { delay } from "redux-saga"; +import { + all, + call, + cancel, + fork, + put, + race, + takeLatest, +} from "redux-saga/effects"; +import { fetchAllDocs, fetchRecentDocs } from "~redux/api/docs"; + +import { + DocsTypes, + fetchDocsFail, + fetchDocsSuccess, + IDocsFetchStartAction, +} from "./actions"; + +function* startSpinner() { + yield delay(300); +} + +function* docsFetchStart(action: IDocsFetchStartAction) { + try { + const spinner = yield fork(startSpinner); + + const { response, timeout } = yield race({ + response: all([call(fetchRecentDocs), call(fetchAllDocs)]), + timeout: call(delay, 10000), + }); + + yield cancel(spinner); + + if (timeout) { + yield put(fetchDocsFail("Timeout")); + return; + } + + if (response) { + if (response[0].data == null || response[1].data == null) { + yield put(fetchDocsFail(response[0].error)); + } else { + const recentDocs = response[0].data; + const allDocs = response[1].data; + yield put(fetchDocsSuccess(recentDocs, allDocs)); + } + } + } catch (e) { + yield put(fetchDocsFail("Internal error")); + } +} + +export function* docsSaga() { + yield all([takeLatest(DocsTypes.DOCS_FETCH_START, docsFetchStart)]); +} diff --git a/frontend/src/redux/reducers.ts b/frontend/src/redux/reducers.ts index eedb765..433e49d 100644 --- a/frontend/src/redux/reducers.ts +++ b/frontend/src/redux/reducers.ts @@ -3,11 +3,13 @@ import { persistReducer } from "redux-persist"; import storage from "redux-persist/lib/storage"; import { authReducer, IAuthState } from "~redux/auth/reducer"; +import { docsReducer, IDocsState } from "./docs/reducer"; import { IUserState, userReducer } from "./user/reducer"; export interface IAppState { auth: IAuthState; user: IUserState; + docs: IDocsState; } const authPersistConfig = { @@ -19,4 +21,5 @@ const authPersistConfig = { export const rootReducer = combineReducers({ auth: persistReducer(authPersistConfig, authReducer), user: userReducer, + docs: docsReducer, }); diff --git a/frontend/src/redux/store.ts b/frontend/src/redux/store.ts index 6dc7290..dfb31b4 100644 --- a/frontend/src/redux/store.ts +++ b/frontend/src/redux/store.ts @@ -5,6 +5,7 @@ import { rootReducer } from "~redux/reducers"; import { setToken } from "./api/utils"; import { authSaga } from "./auth/sagas"; +import { docsSaga } from "./docs/sagas"; import { getUser } from "./user/actions"; import { userSaga } from "./user/sagas"; @@ -22,3 +23,4 @@ export const persistor = persistStore(store, null, () => { sagaMiddleware.run(authSaga); sagaMiddleware.run(userSaga); +sagaMiddleware.run(docsSaga); diff --git a/src/entity/Document.ts b/src/entity/Document.ts index 1eff3fc..0ba71f5 100644 --- a/src/entity/Document.ts +++ b/src/entity/Document.ts @@ -8,10 +8,14 @@ import { import { User } from "./User"; -export type IDocumentJSON = Pick< - Document, - "id" | "user" | "name" | "content" | "createdAt" ->; +export interface IDocumentJSON { + id: number; + user: number; + name: string; + content: string; + createdAt: number; + editedAt: number; +} @Entity() export class Document extends BaseEntity { @@ -41,4 +45,15 @@ export class Document extends BaseEntity { this.name = name; this.content = content; } + + public toJSON(user: number): IDocumentJSON { + return { + id: this.id, + user: user as any, + name: this.name, + content: this.content, + createdAt: this.createdAt.getTime(), + editedAt: this.editedAt.getTime(), + }; + } } diff --git a/src/routes/docs.ts b/src/routes/docs.ts index 3b60db2..556fc77 100644 --- a/src/routes/docs.ts +++ b/src/routes/docs.ts @@ -3,8 +3,6 @@ import { Document } from "~entity/Document"; export const docsRouter = new Router(); -export type IDocumentJSON = Pick; - docsRouter.post("/docs/new", async ctx => { if (!ctx.state.user) { ctx.throw(401); @@ -31,11 +29,7 @@ docsRouter.post("/docs/new", async ctx => { ctx.body = { error: false, - data: { - id: document.id, - name: document.name, - content: document.content, - }, + data: document.toJSON(user.id), }; }); @@ -81,11 +75,7 @@ docsRouter.patch("/docs/byID/:id", async ctx => { ctx.body = { error: false, - data: { - id: document.id, - name: document.name, - content: document.content, - }, + data: document.toJSON(user.id), }; }); @@ -100,11 +90,25 @@ docsRouter.get("/docs/list", async ctx => { ctx.body = { error: false, - data: documents.map(document => ({ - id: document.id, - name: document.name, - content: document.content, - })), + data: documents.map(document => document.toJSON(user.id)), + }; +}); + +docsRouter.get("/docs/list/recent", async ctx => { + if (!ctx.state.user) { + ctx.throw(401); + } + + const { user } = ctx.state; + + const documents = await Document.find({ + where: { user: user.id }, + order: { editedAt: "DESC" }, + }); + + ctx.body = { + error: false, + data: documents.map(document => document.toJSON(user.id)), }; }); @@ -131,11 +135,7 @@ docsRouter.get("/docs/byID/:id", async ctx => { ctx.body = { error: false, - data: { - id: document.id, - name: document.name, - content: document.content, - }, + data: document.toJSON(user.id), }; }); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..88b4e40 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,4 @@ +export interface IAPIResponse { + data: T | null; + error: string | null; +} diff --git a/tests/integration/docs.test.ts b/tests/integration/docs.test.ts index 0e041e3..28150a6 100644 --- a/tests/integration/docs.test.ts +++ b/tests/integration/docs.test.ts @@ -3,8 +3,7 @@ import { connect } from "config/database"; import * as request from "supertest"; import { getConnection } from "typeorm"; import { app } from "~app"; -import { Document } from "~entity/Document"; -import { IDocumentJSON } from "~routes/docs"; +import { Document, IDocumentJSON } from "~entity/Document"; import { ISeed, seedDB } from "./util"; @@ -89,12 +88,38 @@ describe("docs", () => { const documents = response.body.data as IDocumentJSON[]; + const userDocs = [seed.doc1.toJSON(seed.user1.id)]; + + expect(documents).to.deep.equal(userDocs); + }); + + it("should list recent docs", async () => { + const doc1 = new Document(seed.user1, "doc1", ""); + doc1.editedAt = new Date(doc1.editedAt.getTime() + 10000); + await doc1.save(); + const doc2 = new Document(seed.user1, "doc2", ""); + doc2.editedAt = new Date(doc2.editedAt.getTime() + 20000); + await doc2.save(); + const doc3 = new Document(seed.user1, "doc3", ""); + doc3.editedAt = new Date(doc3.editedAt.getTime() + 30000); + await doc3.save(); + + const response = await request(callback) + .get("/docs/list/recent") + .set({ + Authorization: `Bearer ${seed.user1.toJWT()}`, + }) + .expect(200); + + expect(response.body.error).to.be.false; + + const documents = response.body.data as IDocumentJSON[]; + const userDocs = [ - { - id: seed.doc1.id, - name: seed.doc1.name, - content: seed.doc1.content, - }, + doc3.toJSON(seed.user1.id), + doc2.toJSON(seed.user1.id), + doc1.toJSON(seed.user1.id), + seed.doc1.toJSON(seed.user1.id), ]; expect(documents).to.deep.equal(userDocs); @@ -112,11 +137,7 @@ describe("docs", () => { const document = response.body.data as IDocumentJSON; - const usedDoc = { - id: seed.doc1.id, - name: seed.doc1.name, - content: seed.doc1.content, - }; + const usedDoc = seed.doc1.toJSON(seed.user1.id); expect(document).to.deep.equal(usedDoc); });