@stanlemon/app-template 0.2.8 → 0.2.12

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
@@ -3,19 +3,16 @@ import {
3
3
  asyncJsonHandler as handler,
4
4
  SimpleUsersDao,
5
5
  } from "@stanlemon/server-with-auth";
6
- import { Low, JSONFile } from "lowdb";
7
-
8
- const adapter = new JSONFile("./db.json");
9
6
 
7
+ const dao = new SimpleUsersDao();
10
8
  const app = createAppServer({
11
9
  webpack: "http://localhost:8080",
12
10
  secure: ["/api/"],
13
- ...new SimpleUsersDao([], adapter),
11
+ ...dao,
14
12
  });
15
13
 
16
- const db = new Low(adapter);
17
- await db.read();
18
- db.data.items ||= [];
14
+ export const db = dao.getDb();
15
+ db.read().then(() => (db.data.items = db.data.items || []));
19
16
 
20
17
  app.get(
21
18
  "/api/items",
@@ -24,9 +21,20 @@ app.get(
24
21
 
25
22
  app.post(
26
23
  "/api/items",
27
- handler(async (item) => {
28
- db.data.items.push(item);
24
+ handler(async ({ item }) => {
25
+ db.data.items.push({ item, id: dao.generateId() });
29
26
  await db.write();
30
27
  return db.data.items;
31
28
  })
32
29
  );
30
+
31
+ app.delete(
32
+ "/api/items/:id",
33
+ handler(async ({ id }) => {
34
+ db.data.items = db.data.items.filter((item) => id !== item.id);
35
+ await db.write();
36
+ return db.data.items;
37
+ })
38
+ );
39
+
40
+ export default app;
package/app.test.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ import request from "supertest";
5
+ import app, { db } from "./app.js";
6
+
7
+ describe("/app", () => {
8
+ afterEach(() => {
9
+ // Prevent data from bleeding over after each test
10
+ db.data.items = [];
11
+ });
12
+
13
+ it("lists items", async () => {
14
+ const response = await request(app)
15
+ .get("/api/items")
16
+ .set("Accept", "application/json");
17
+
18
+ expect(response.headers["content-type"]).toMatch(/json/);
19
+ expect(response.status).toEqual(200);
20
+ expect(response.body).toEqual([]);
21
+ });
22
+
23
+ it("add item", async () => {
24
+ const response = await request(app)
25
+ .post("/api/items")
26
+ .set("Accept", "application/json")
27
+ .send({ item: "hello world" });
28
+
29
+ expect(response.headers["content-type"]).toMatch(/json/);
30
+ expect(response.status).toEqual(200);
31
+ expect(response.body).toMatchObject([{ item: "hello world" }]);
32
+ });
33
+
34
+ it("delete item", async () => {
35
+ const response1 = await request(app)
36
+ .post("/api/items")
37
+ .set("Accept", "application/json")
38
+ .send({ item: "hello world" });
39
+
40
+ const items = response1.body;
41
+
42
+ const response2 = await request(app)
43
+ .delete(`/api/items/${items[0].id}`)
44
+ .set("Accept", "application/json");
45
+
46
+ expect(response2.headers["content-type"]).toMatch(/json/);
47
+ expect(response2.status).toEqual(200);
48
+ expect(response2.body).toMatchObject([]);
49
+ });
50
+ });
package/jest.config.js CHANGED
@@ -1 +1 @@
1
- export { default } from "@stanlemon/webdev/jest.config.js";
1
+ export { default } from "@stanlemon/webdev/jest.config.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stanlemon/app-template",
3
- "version": "0.2.8",
3
+ "version": "0.2.12",
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",
@@ -18,15 +18,16 @@
18
18
  "lint:format": "eslint --fix --ext js,jsx,ts,tsx ./src/"
19
19
  },
20
20
  "dependencies": {
21
- "@stanlemon/server-with-auth": "0.1.4",
21
+ "@stanlemon/server-with-auth": "*",
22
22
  "@stanlemon/webdev": "*",
23
- "react": "^18.0.0",
24
- "react-dom": "^18.0.0"
23
+ "react": "^18.1.0",
24
+ "react-dom": "^18.1.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@testing-library/react": "^13.1.1",
28
28
  "@testing-library/user-event": "^14.1.1",
29
- "@types/react": "^18.0.6",
30
- "@types/react-dom": "^18.0.2"
29
+ "@types/react": "^18.0.8",
30
+ "@types/react-dom": "^18.0.3",
31
+ "supertest": "^6.2.3"
31
32
  }
32
33
  }
package/src/App.test.tsx CHANGED
@@ -1,18 +1,15 @@
1
- import {
2
- render,
3
- screen,
4
- fireEvent,
5
- waitFor,
6
- act,
7
- } from "@testing-library/react";
1
+ import { render, screen, fireEvent, act } from "@testing-library/react";
8
2
  import userEvent from "@testing-library/user-event";
9
- import App from "./App";
3
+ import App, { ItemData } from "./App";
10
4
  import { SessionContext } from "./Session";
11
5
 
12
- const output = ["item one", "item two"];
6
+ const output = [
7
+ { id: "1", item: "item one" },
8
+ { id: "2", item: "item two" },
9
+ ];
13
10
  global.fetch = jest.fn((url, opts: { method: string; body: string }) => {
14
11
  if (opts.method === "post") {
15
- output.push(JSON.parse(opts.body) as string);
12
+ output.push(JSON.parse(opts.body) as ItemData);
16
13
  }
17
14
  return Promise.resolve({
18
15
  ok: true,
@@ -21,42 +18,36 @@ global.fetch = jest.fn((url, opts: { method: string; body: string }) => {
21
18
  }) as jest.Mock;
22
19
 
23
20
  test("<App/>", async () => {
24
- act(() => {
25
- render(
26
- <SessionContext.Provider
27
- value={{
28
- session: {
29
- token: "abcd",
30
- user: {
31
- username: "user",
32
- password: "password",
33
- name: "user",
34
- email: "user@example.com",
35
- },
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",
36
31
  },
37
- setSession: () => {},
38
- }}
39
- >
40
- <App />
41
- </SessionContext.Provider>
42
- );
43
- });
32
+ },
33
+ setSession: () => {},
34
+ }}
35
+ >
36
+ <App />
37
+ </SessionContext.Provider>
38
+ );
44
39
 
45
40
  // The auth text is present
46
- expect(screen.getByText("You logged in as user")).toBeInTheDocument();
41
+ expect(screen.getByText("You are logged in as user.")).toBeInTheDocument();
47
42
 
48
43
  // The header is present
49
44
  expect(
50
45
  screen.getByRole("heading", { name: "Hello World!" })
51
46
  ).toBeInTheDocument();
52
47
 
53
- expect(
54
- await screen.findByText("item one", { selector: "li" })
55
- ).toBeInTheDocument();
48
+ expect(await screen.findByText("item one")).toBeInTheDocument();
56
49
 
57
- expect(
58
- await screen.findByText("item two", { selector: "li" })
59
- ).toBeInTheDocument();
50
+ expect(await screen.findByText("item two")).toBeInTheDocument();
60
51
 
61
52
  // Type some data into the input
62
53
  await userEvent.type(screen.getByLabelText("Item"), "item three");
@@ -67,7 +58,5 @@ test("<App/>", async () => {
67
58
  });
68
59
 
69
60
  // Now we should have a list item with the text we entered
70
- expect(
71
- await screen.findByText("item three", { selector: "li" })
72
- ).toBeInTheDocument();
61
+ expect(await screen.findByText("item three")).toBeInTheDocument();
73
62
  });
package/src/App.tsx CHANGED
@@ -1,4 +1,5 @@
1
- import { useState, useContext, useEffect } from "react";
1
+ import React, { useState, useContext, useEffect } from "react";
2
+
2
3
  import "./App.less";
3
4
  import { SessionContext } from "./Session";
4
5
  import Header from "./Header";
@@ -6,7 +7,7 @@ import Input from "./Input";
6
7
  import Login from "./Login";
7
8
  import Register from "./Register";
8
9
 
9
- export type ErrorMessage = {
10
+ export type ErrorResponse = {
10
11
  message: string;
11
12
  };
12
13
 
@@ -14,40 +15,81 @@ export type FormErrors = {
14
15
  errors: Record<string, string>;
15
16
  };
16
17
 
18
+ export type ItemData = {
19
+ id: string;
20
+ item: string;
21
+ };
22
+
23
+ // eslint-disable-next-line max-lines-per-function
17
24
  export default function App() {
18
- const [value, setValue] = useState<string>("");
19
- const [items, setItems] = useState<string[]>([]);
25
+ const [loaded, setLoaded] = useState<boolean>(false);
26
+ const [items, setItems] = useState<ItemData[]>([]);
27
+ const [error, setError] = useState<string | boolean>(false);
20
28
 
21
- const { session } = useContext(SessionContext);
29
+ const { session, setSession } = useContext(SessionContext);
30
+
31
+ const catchError = (err: Error) => {
32
+ if (err.message === "Unauthorized") {
33
+ return;
34
+ }
35
+ setError(err.message);
36
+ };
22
37
 
23
- const itemsJson = JSON.stringify(items);
24
38
  useEffect(() => {
25
- fetchApi("/api/items", session?.token || "")
26
- .then((items: string[]) => {
39
+ if (loaded) {
40
+ return;
41
+ }
42
+
43
+ fetchApi<ItemData[], null>("/api/items", session?.token || "")
44
+ .then((items) => {
45
+ setLoaded(true);
27
46
  setItems(items);
28
47
  })
29
- .catch((err) => console.error(err));
30
- }, [itemsJson, session?.token]);
48
+ .catch(catchError);
49
+ });
31
50
 
32
- const addItem = () => {
33
- setValue("");
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
+ };
34
65
 
35
- fetchApi("/api/items", session?.token || "", "post", value)
36
- .then((items: string[]) => {
66
+ const deleteItem = (id: string) => {
67
+ fetchApi<ItemData[], string>(
68
+ `/api/items/${id}`,
69
+ session?.token || "",
70
+ "delete"
71
+ )
72
+ .then((items) => {
37
73
  setItems(items);
38
74
  })
39
- .catch((err) => console.error(err));
75
+ .catch(catchError);
76
+ };
77
+
78
+ const logout = () => {
79
+ setSession({ token: null, user: null });
40
80
  };
41
81
 
42
82
  return (
43
83
  <>
44
84
  <Header />
85
+ <ErrorMessage error={error} />
45
86
  {!session.user && (
46
87
  <Row>
47
88
  <Column>
48
89
  <h2>Login</h2>
49
90
  <Login />
50
91
  </Column>
92
+ <Column />
51
93
  <Column>
52
94
  <h2>Register</h2>
53
95
  <Register />
@@ -57,63 +99,128 @@ export default function App() {
57
99
  {session.user && (
58
100
  <>
59
101
  <p>
60
- <em>You logged in as {session.user?.username}</em>
102
+ <em>You are logged in as {session.user?.username}.</em>{" "}
103
+ <span style={{ cursor: "pointer" }} onClick={logout}>
104
+ (logout)
105
+ </span>
61
106
  </p>
62
- <Input
63
- label="Item"
64
- name="item"
65
- value={value}
66
- onChange={(value) => setValue(value)}
67
- onEnter={addItem}
68
- />
69
- <button onClick={addItem}>Add</button>
70
- <ul>
71
- {items.map((item, i) => (
72
- <li key={i}>{item}</li>
73
- ))}
74
- </ul>
107
+ <ItemList items={items} saveItem={saveItem} deleteItem={deleteItem} />
75
108
  </>
76
109
  )}
110
+ <Spacer />
77
111
  </>
78
112
  );
79
113
  }
80
114
 
81
- function Row({ children }: { children: React.ReactNode }) {
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
+
82
131
  return (
83
- <div
84
- style={{
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: {
85
172
  display: "flex",
86
173
  flexDirection: "row",
87
174
  flexWrap: "wrap",
88
175
  width: "100%",
89
- }}
90
- >
91
- {children}
92
- </div>
176
+ },
177
+ },
178
+ children
93
179
  );
94
180
  }
95
181
 
96
- function Column({ children }: { children: React.ReactNode }) {
97
- return (
98
- <div
99
- style={{
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: {
100
197
  display: "flex",
101
198
  flexDirection: "column",
102
199
  flexBasis: "100%",
103
- flex: 1,
104
- }}
105
- >
106
- {children}
107
- </div>
200
+ flex: "1 1 0",
201
+ },
202
+ },
203
+ children
108
204
  );
109
205
  }
110
206
 
111
- function fetchApi(
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>(
112
219
  url: string,
113
220
  token: string,
114
221
  method = "get",
115
- data?: any
116
- ): Promise<any> {
222
+ data?: P
223
+ ): Promise<T> {
117
224
  return fetch(url, {
118
225
  method: method,
119
226
  headers: {
@@ -122,12 +229,10 @@ function fetchApi(
122
229
  "Content-Type": "application/json",
123
230
  },
124
231
  body: JSON.stringify(data),
125
- })
126
- .then((response) => {
127
- if (!response.ok) {
128
- throw new Error(response.statusText);
129
- }
130
- return response;
131
- })
132
- .then((response) => response.json());
232
+ }).then((response) => {
233
+ if (!response.ok) {
234
+ throw new Error(response.statusText);
235
+ }
236
+ return response.json() as Promise<T>;
237
+ });
133
238
  }
package/src/Login.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useState, useContext } from "react";
2
- import { ErrorMessage } from "./App";
2
+ import { ErrorResponse, Spacer } from "./App";
3
3
  import { SessionContext, SessionData, UserData } from "./Session";
4
4
  import Input from "./Input";
5
5
 
@@ -44,17 +44,17 @@ export default function Login() {
44
44
  if (ok) {
45
45
  setSession(data as SessionData);
46
46
  } else {
47
- setError((data as ErrorMessage).message);
47
+ setError((data as ErrorResponse).message);
48
48
  }
49
49
  }
50
50
  )
51
- .catch((err) => {
52
- console.error("error", err);
51
+ .catch((err: Error) => {
52
+ setError(err.message);
53
53
  });
54
54
  };
55
55
 
56
56
  return (
57
- <div>
57
+ <>
58
58
  {error && (
59
59
  <div>
60
60
  <strong>{error}</strong>
@@ -75,7 +75,8 @@ export default function Login() {
75
75
  onChange={(value) => setValues({ ...values, password: value })}
76
76
  onEnter={onSubmit}
77
77
  />
78
+ <Spacer />
78
79
  <button onClick={onSubmit}>Login</button>
79
- </div>
80
+ </>
80
81
  );
81
82
  }
package/src/Register.tsx CHANGED
@@ -1,10 +1,11 @@
1
1
  import { useState, useContext } from "react";
2
- import { FormErrors } from "./App";
2
+ import { FormErrors, ErrorMessage, Spacer } from "./App";
3
3
  import { SessionData, UserData, SessionContext } from "./Session";
4
4
  import Input from "./Input";
5
5
 
6
6
  // eslint-disable-next-line max-lines-per-function
7
7
  export default function Register() {
8
+ const [error, setError] = useState<string | boolean>(false);
8
9
  const [errors, setErrors] = useState<Record<string, string>>({});
9
10
  const [values, setValues] = useState<UserData>({
10
11
  name: "",
@@ -49,13 +50,15 @@ export default function Register() {
49
50
  }
50
51
  }
51
52
  )
52
- .catch((err) => {
53
- console.error("error", err);
53
+ .catch((err: Error) => {
54
+ // Put any generis error onto the username field
55
+ setError(err.message);
54
56
  });
55
57
  };
56
58
 
57
59
  return (
58
- <div>
60
+ <>
61
+ <ErrorMessage error={error} />
59
62
  <Input
60
63
  name="username"
61
64
  label="Username"
@@ -72,7 +75,8 @@ export default function Register() {
72
75
  onChange={(value) => setValues({ ...values, password: value })}
73
76
  error={errors.password}
74
77
  />
78
+ <Spacer />
75
79
  <button onClick={onSubmit}>Register</button>
76
- </div>
80
+ </>
77
81
  );
78
82
  }
package/src/Session.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import React, { useState, useEffect, createContext } from "react";
2
+ import { ErrorMessage } from "./App";
2
3
 
3
4
  export const SessionContext = createContext<{
4
5
  session: SessionData;
@@ -23,8 +24,9 @@ export type UserData = {
23
24
  password: string;
24
25
  };
25
26
 
26
- export default function Session({ children }: { children: React.ReactChild }) {
27
+ export default function Session({ children }: { children: React.ReactNode }) {
27
28
  const [initialized, setInitialized] = useState<boolean>(false);
29
+ const [error, setError] = useState<string | boolean>(false);
28
30
  const [session, setSession] = useState<SessionData>({
29
31
  token: null,
30
32
  user: null,
@@ -42,7 +44,7 @@ export default function Session({ children }: { children: React.ReactChild }) {
42
44
  setInitialized(true);
43
45
 
44
46
  if (!response.ok) {
45
- throw new Error(response.statusText);
47
+ throw new ErrorMessage(response.statusText);
46
48
  }
47
49
  return response;
48
50
  })
@@ -50,11 +52,18 @@ export default function Session({ children }: { children: React.ReactChild }) {
50
52
  .then((session: SessionData) => {
51
53
  setSession(session);
52
54
  })
53
- .catch((err) => {
54
- console.error(err);
55
+ .catch((err: Error) => {
56
+ if (err.message === "Unauthorized") {
57
+ return;
58
+ }
59
+ setError(err.message);
55
60
  });
56
61
  }, [session?.token, initialized]);
57
62
 
63
+ if (error) {
64
+ return <ErrorMessage error={error} />;
65
+ }
66
+
58
67
  if (!initialized) {
59
68
  return (
60
69
  <div>