@stanlemon/app-template 0.3.20 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js CHANGED
@@ -1,20 +1,37 @@
1
+ import EventEmitter from "node:events";
1
2
  import {
2
3
  createAppServer,
4
+ createSchemas,
3
5
  asyncJsonHandler as handler,
4
- createDb,
6
+ createLowDb,
7
+ EVENTS,
5
8
  LowDBUserDao,
6
9
  } from "@stanlemon/server-with-auth";
10
+ import Joi from "joi";
7
11
  import { v4 as uuid } from "uuid";
8
12
 
9
- export const db = createDb();
13
+ export const db = createLowDb();
10
14
  const dao = new LowDBUserDao(db);
11
15
 
12
16
  db.data.items = db.data.items || [];
13
17
 
18
+ const eventEmitter = new EventEmitter();
19
+ eventEmitter.on(EVENTS.USER_CREATED, (user) => {
20
+ // eslint-disable-next-line no-console
21
+ console.log("New user signed up!", user);
22
+ // Now send an email so they can verify!
23
+ });
24
+
14
25
  export const app = createAppServer({
15
26
  webpack: "http://localhost:8080",
16
27
  secure: ["/api/"],
17
28
  dao,
29
+ eventEmitter,
30
+ schemas: createSchemas({
31
+ name: Joi.string().required().label("Name"),
32
+ email: Joi.string().email().required().label("Email"),
33
+ }),
34
+ jwtExpireInMinutes: 3, // Customize the jwt session window, default is 10
18
35
  });
19
36
 
20
37
  app.get(
@@ -39,3 +56,6 @@ app.delete(
39
56
  return db.data.items;
40
57
  })
41
58
  );
59
+
60
+ app.spa();
61
+ app.catch404s("/api/*");
package/package.json CHANGED
@@ -1,35 +1,39 @@
1
1
  {
2
2
  "name": "@stanlemon/app-template",
3
- "version": "0.3.20",
3
+ "version": "0.3.21",
4
4
  "description": "A template for creating apps using the webdev package.",
5
5
  "author": "Stan Lemon <stanlemon@users.noreply.github.com>",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "scripts": {
9
9
  "start": "node app.js",
10
+ "start:prod": "NODE_ENV=production node app.js",
11
+ "start:dev": "NODE_ENV=development nodemon --ignore ./db.json app.js",
10
12
  "build": "npm run webpack:build",
11
13
  "tsc": "tsc",
12
14
  "webpack:serve": "webpack serve",
13
15
  "webpack:build": "NODE_ENV=production webpack",
14
- "dev": "concurrently \"npm run webpack:serve\" \"npm run start\"",
15
- "test": "jest",
16
+ "dev": "NODE_ENV=development concurrently \"npm run webpack:serve\" \"npm run start:dev\"",
17
+ "test": "jest --detectOpenHandles",
16
18
  "test:watch": "jest -w",
17
19
  "test:coverage": "jest --coverage",
18
20
  "lint": "eslint --ext js,jsx,ts,tsx ./src/",
19
- "lint:format": "eslint --fix --ext js,jsx,ts,tsx ./src/"
21
+ "lint:fix": "eslint --fix --ext js,jsx,ts,tsx ./src/"
20
22
  },
21
23
  "dependencies": {
22
24
  "@stanlemon/server-with-auth": "*",
23
25
  "@stanlemon/webdev": "*",
24
26
  "react": "^18.2.0",
25
- "react-dom": "^18.2.0"
27
+ "react-cookie": "^6.1.3",
28
+ "react-dom": "^18.2.0",
29
+ "wouter": "^2.12.1"
26
30
  },
27
31
  "devDependencies": {
28
32
  "@testing-library/react": "^14.1.2",
29
33
  "@testing-library/user-event": "^14.5.2",
30
- "@types/react": "^18.2.46",
34
+ "@types/react": "^18.2.47",
31
35
  "@types/react-dom": "^18.2.18",
32
36
  "concurrently": "^8.2.2",
33
37
  "supertest": "^6.3.3"
34
38
  }
35
- }
39
+ }
package/src/App.test.tsx CHANGED
@@ -1,62 +1,69 @@
1
- import { render, screen, fireEvent, act } from "@testing-library/react";
2
- import userEvent from "@testing-library/user-event";
3
- import App, { ItemData } from "./App";
4
- import { SessionContext } from "./Session";
5
-
6
- const output = [
7
- { id: "1", item: "item one" },
8
- { id: "2", item: "item two" },
9
- ];
10
- global.fetch = jest.fn((url, opts: { method: string; body: string }) => {
11
- if (opts.method === "post") {
12
- output.push(JSON.parse(opts.body) as ItemData);
13
- }
14
- return Promise.resolve({
15
- ok: true,
16
- json: () => Promise.resolve(output),
1
+ import "@testing-library/jest-dom";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import App from "./App";
4
+ import { ItemData } from "./views";
5
+ import { SessionAware } from "./Session";
6
+ import fetchApi from "./helpers/fetchApi";
7
+
8
+ jest.mock("./helpers/fetchApi");
9
+
10
+ describe("<App/>", () => {
11
+ beforeEach(() => {
12
+ jest.resetAllMocks();
17
13
  });
18
- }) as jest.Mock;
19
-
20
- test("<App/>", async () => {
21
- render(
22
- <SessionContext.Provider
23
- value={{
24
- session: {
25
- token: "abcd",
26
- user: {
27
- username: "user",
28
- password: "password",
29
- name: "user",
30
- email: "user@example.com",
31
- },
32
- },
33
- setSession: () => {},
34
- }}
35
- >
36
- <App />
37
- </SessionContext.Provider>
38
- );
39
-
40
- // The auth text is present
41
- expect(screen.getByText("You are logged in as user.")).toBeInTheDocument();
42
-
43
- // The header is present
44
- expect(
45
- screen.getByRole("heading", { name: "Hello World!" })
46
- ).toBeInTheDocument();
47
-
48
- expect(await screen.findByText("item one")).toBeInTheDocument();
49
-
50
- expect(await screen.findByText("item two")).toBeInTheDocument();
51
-
52
- // Type some data into the input
53
- await userEvent.type(screen.getByLabelText("Item"), "item three");
54
-
55
- // Click the add button
56
- act(() => {
57
- fireEvent.click(screen.getByText("Add", { selector: "button" }));
14
+
15
+ it("logged out", async () => {
16
+ render(
17
+ <SessionAware initialized={true} token={null} user={null}>
18
+ <App />
19
+ </SessionAware>
20
+ );
21
+
22
+ expect(
23
+ screen.getByRole("heading", { name: "Hello World!" })
24
+ ).toBeInTheDocument();
25
+
26
+ expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument();
27
+
28
+ expect(
29
+ screen.getByRole("heading", { name: "Sign Up" })
30
+ ).toBeInTheDocument();
58
31
  });
59
32
 
60
- // Now we should have a list item with the text we entered
61
- expect(await screen.findByText("item three")).toBeInTheDocument();
33
+ it("logged in", async () => {
34
+ const mockedFetchApi = fetchApi as jest.MockedFunction<
35
+ typeof fetchApi<ItemData[], null>
36
+ >;
37
+ mockedFetchApi.mockResolvedValue([]);
38
+
39
+ render(
40
+ <SessionAware
41
+ initialized={true}
42
+ token="abcd"
43
+ user={{
44
+ username: "user",
45
+ name: "user",
46
+ email: "user@example.com",
47
+ }}
48
+ >
49
+ <App />
50
+ </SessionAware>
51
+ );
52
+
53
+ // The header is present
54
+ expect(
55
+ screen.getByRole("heading", { name: "Hello World!" })
56
+ ).toBeInTheDocument();
57
+
58
+ // The auth text is present
59
+ expect(
60
+ screen.queryByText("You are logged in as", { exact: false })
61
+ ).toBeInTheDocument();
62
+
63
+ await waitFor(() => {
64
+ expect(
65
+ screen.getByRole("heading", { name: "New Item" })
66
+ ).toBeInTheDocument();
67
+ });
68
+ });
62
69
  });
package/src/App.tsx CHANGED
@@ -1,238 +1,87 @@
1
- import React, { useState, useContext, useEffect } from "react";
2
-
1
+ import { useContext, useEffect } from "react";
2
+ import { useCookies } from "react-cookie";
3
+ import { Switch, Route, Link } from "wouter";
3
4
  import "./App.less";
4
5
  import { SessionContext } from "./Session";
5
- import Header from "./Header";
6
- import Input from "./Input";
7
- import Login from "./Login";
8
- import Register from "./Register";
9
-
10
- export type ErrorResponse = {
11
- message: string;
12
- };
13
-
14
- export type FormErrors = {
15
- errors: Record<string, string>;
16
- };
6
+ import {
7
+ Column,
8
+ ErrorMessage,
9
+ SuccessMessage,
10
+ Header,
11
+ Row,
12
+ Spacer,
13
+ } from "./components/";
14
+ import { Login, SignUp, Verify, Items, Account } from "./views/";
17
15
 
18
- export type ItemData = {
19
- id: string;
20
- item: string;
21
- };
22
-
23
- // eslint-disable-next-line max-lines-per-function
24
16
  export default function App() {
25
- const [loaded, setLoaded] = useState<boolean>(false);
26
- const [items, setItems] = useState<ItemData[]>([]);
27
- const [error, setError] = useState<string | boolean>(false);
28
-
29
- const { session, setSession } = useContext(SessionContext);
17
+ const { error, message, user, setUser, setToken, setMessage } =
18
+ useContext(SessionContext);
19
+ const [, , removeCookie] = useCookies(["session_token"]);
30
20
 
31
- const catchError = (err: Error) => {
32
- if (err.message === "Unauthorized") {
33
- return;
34
- }
35
- setError(err.message);
21
+ const logout = () => {
22
+ removeCookie("session_token", { path: "/" });
23
+ setToken(null);
24
+ setUser(null);
36
25
  };
37
26
 
38
27
  useEffect(() => {
39
- if (loaded) {
40
- return;
41
- }
28
+ // After 10 seconds, clear out any messages
29
+ const timer = setTimeout(() => {
30
+ setMessage(null);
31
+ }, 1000 * 10);
42
32
 
43
- fetchApi<ItemData[], null>("/api/items", session?.token || "")
44
- .then((items) => {
45
- setLoaded(true);
46
- setItems(items);
47
- })
48
- .catch(catchError);
33
+ return () => clearTimeout(timer);
49
34
  });
50
35
 
51
- const saveItem = (item: string) => {
52
- fetchApi<ItemData[], { item: string }>(
53
- "/api/items",
54
- session?.token || "",
55
- "post",
56
- {
57
- item,
58
- }
59
- )
60
- .then((items) => {
61
- setItems(items);
62
- })
63
- .catch(catchError);
64
- };
65
-
66
- const deleteItem = (id: string) => {
67
- fetchApi<ItemData[], string>(
68
- `/api/items/${id}`,
69
- session?.token || "",
70
- "delete"
71
- )
72
- .then((items) => {
73
- setItems(items);
74
- })
75
- .catch(catchError);
76
- };
77
-
78
- const logout = () => {
79
- setSession({ token: null, user: null });
80
- };
81
-
82
36
  return (
83
37
  <>
84
38
  <Header />
85
39
  <ErrorMessage error={error} />
86
- {!session.user && (
87
- <Row>
88
- <Column>
89
- <h2>Login</h2>
90
- <Login />
91
- </Column>
92
- <Column />
93
- <Column>
94
- <h2>Register</h2>
95
- <Register />
96
- </Column>
97
- </Row>
40
+ <SuccessMessage message={message} />
41
+ {!user && (
42
+ <Switch>
43
+ <Route path="/">
44
+ <Row>
45
+ <Column>
46
+ <h2>Login</h2>
47
+ <Login />
48
+ </Column>
49
+ <Column />
50
+ <Column>
51
+ <h2>Sign Up</h2>
52
+ <SignUp />
53
+ </Column>
54
+ </Row>
55
+ </Route>
56
+ <Route path="/verify/:token">
57
+ {({ token: verificationToken }: { token: string }) => (
58
+ <Verify token={verificationToken} />
59
+ )}
60
+ </Route>
61
+ </Switch>
98
62
  )}
99
- {session.user && (
63
+ {user && (
100
64
  <>
101
65
  <p>
102
- <em>You are logged in as {session.user?.username}.</em>{" "}
66
+ <em>
67
+ You are logged in as <Link href="/account">{user?.username}</Link>
68
+ .
69
+ </em>{" "}
103
70
  <span style={{ cursor: "pointer" }} onClick={logout}>
104
71
  (logout)
105
72
  </span>
106
73
  </p>
107
- <ItemList items={items} saveItem={saveItem} deleteItem={deleteItem} />
74
+ <Switch>
75
+ <Route path="/">
76
+ <Items />
77
+ </Route>
78
+ <Route path="/account">
79
+ <Account />
80
+ </Route>
81
+ </Switch>
82
+ <Spacer />
108
83
  </>
109
84
  )}
110
- <Spacer />
111
85
  </>
112
86
  );
113
87
  }
114
-
115
- function ItemList({
116
- items,
117
- saveItem,
118
- deleteItem,
119
- }: {
120
- items: ItemData[];
121
- saveItem(item: string): void;
122
- deleteItem(item: string): void;
123
- }) {
124
- const [value, setValue] = useState<string>("");
125
-
126
- const addItem = () => {
127
- saveItem(value);
128
- setValue("");
129
- };
130
-
131
- return (
132
- <>
133
- <h2>New Item</h2>
134
- <Input
135
- label="Item"
136
- name="item"
137
- value={value}
138
- onChange={(value) => setValue(value)}
139
- onEnter={addItem}
140
- />
141
- <button onClick={addItem}>Add</button>
142
- <Spacer />
143
- <h2>My Items</h2>
144
- <ul style={{ padding: 0 }}>
145
- {items.map(({ item, id }, i) => (
146
- <Row key={i} as="li">
147
- <button
148
- style={{ marginLeft: "auto", order: 2 }}
149
- onClick={() => deleteItem(id)}
150
- >
151
- Delete
152
- </button>
153
- <div>{item}</div>
154
- </Row>
155
- ))}
156
- </ul>
157
- </>
158
- );
159
- }
160
-
161
- export function Row({
162
- as = "div",
163
- children,
164
- }: {
165
- as?: keyof JSX.IntrinsicElements;
166
- children?: React.ReactNode;
167
- }) {
168
- return React.createElement(
169
- as,
170
- {
171
- style: {
172
- display: "flex",
173
- flexDirection: "row",
174
- flexWrap: "wrap",
175
- width: "100%",
176
- },
177
- },
178
- children
179
- );
180
- }
181
-
182
- export function Spacer({ height = "2em" }: { height?: string | number }) {
183
- return <div style={{ height, minHeight: height }} />;
184
- }
185
-
186
- export function Column({
187
- as = "div",
188
- children,
189
- }: {
190
- as?: keyof JSX.IntrinsicElements;
191
- children?: React.ReactNode;
192
- }) {
193
- return React.createElement(
194
- as,
195
- {
196
- style: {
197
- display: "flex",
198
- flexDirection: "column",
199
- flexBasis: "100%",
200
- flex: "1 1 0",
201
- },
202
- },
203
- children
204
- );
205
- }
206
-
207
- export function ErrorMessage({ error }: { error: string | boolean }) {
208
- if (error) {
209
- return (
210
- <p>
211
- <strong>An error has occurred:</strong> {error}
212
- </p>
213
- );
214
- }
215
- return <></>;
216
- }
217
-
218
- function fetchApi<T, P>(
219
- url: string,
220
- token: string,
221
- method = "get",
222
- data?: P
223
- ): Promise<T> {
224
- return fetch(url, {
225
- method: method,
226
- headers: {
227
- Authorization: `Bearer ${token}`,
228
- Accept: "application/json",
229
- "Content-Type": "application/json",
230
- },
231
- body: JSON.stringify(data),
232
- }).then((response) => {
233
- if (!response.ok) {
234
- throw new Error(response.statusText);
235
- }
236
- return response.json() as Promise<T>;
237
- });
238
- }