simple docs ui

This commit is contained in:
2019-02-07 22:19:39 +03:00
parent acaf29f0e5
commit 0713f41d72
15 changed files with 384 additions and 81 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,5 @@
import * as React from "react";
export function List() {
return <div />;
}

View File

@@ -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 => (
<div key={doc.id}>
<H1>{doc.name}</H1>
<p>{doc.content}</p>
</div>
));
}
return docsList || <div>Loading</div>;
}
}
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);

View File

@@ -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<IHomeProps> {
/>
</Navbar.Group>
</Navbar>
<div id="MainScreen" />
<div id="MainScreen">
<Switch>
<Route exact={true} path="/" component={Overview} />
</Switch>
</div>
</>
)
);

View File

@@ -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<IDocumentJSON[]>
> {
return fetchJSONAuth("/docs/list/recent", "GET");
}
export async function fetchAllDocs(): Promise<IAPIResponse<IDocumentJSON[]>> {
return fetchJSONAuth("/docs/list", "GET");
}

View File

@@ -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;

View File

@@ -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<IDocsState, DocsAction> = (
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;
}
};

View File

@@ -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)]);
}

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -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(),
};
}
}

View File

@@ -3,8 +3,6 @@ import { Document } from "~entity/Document";
export const docsRouter = new Router();
export type IDocumentJSON = Pick<Document, "id" | "name" | "content">;
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),
};
});

4
src/types.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface IAPIResponse<T> {
data: T | null;
error: string | null;
}

View File

@@ -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);
});