@stanlemon/app-template 0.2.7 → 0.2.10
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 +9 -0
- package/package.json +5 -5
- package/src/App.test.tsx +22 -36
- package/src/App.tsx +146 -55
- package/src/Login.tsx +7 -6
- package/src/Register.tsx +9 -5
- package/src/Session.tsx +13 -4
package/app.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stanlemon/app-template",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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",
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@stanlemon/server-with-auth": "0.1.4",
|
|
22
22
|
"@stanlemon/webdev": "*",
|
|
23
|
-
"react": "^18.
|
|
24
|
-
"react-dom": "^18.
|
|
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.
|
|
30
|
-
"@types/react-dom": "^18.0.
|
|
29
|
+
"@types/react": "^18.0.8",
|
|
30
|
+
"@types/react-dom": "^18.0.3"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/src/App.test.tsx
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
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
3
|
import App from "./App";
|
|
10
4
|
import { SessionContext } from "./Session";
|
|
@@ -21,42 +15,36 @@ global.fetch = jest.fn((url, opts: { method: string; body: string }) => {
|
|
|
21
15
|
}) as jest.Mock;
|
|
22
16
|
|
|
23
17
|
test("<App/>", async () => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
email: "user@example.com",
|
|
35
|
-
},
|
|
18
|
+
render(
|
|
19
|
+
<SessionContext.Provider
|
|
20
|
+
value={{
|
|
21
|
+
session: {
|
|
22
|
+
token: "abcd",
|
|
23
|
+
user: {
|
|
24
|
+
username: "user",
|
|
25
|
+
password: "password",
|
|
26
|
+
name: "user",
|
|
27
|
+
email: "user@example.com",
|
|
36
28
|
},
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
},
|
|
30
|
+
setSession: () => {},
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<App />
|
|
34
|
+
</SessionContext.Provider>
|
|
35
|
+
);
|
|
44
36
|
|
|
45
37
|
// The auth text is present
|
|
46
|
-
expect(screen.getByText("You logged in as user")).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText("You are logged in as user.")).toBeInTheDocument();
|
|
47
39
|
|
|
48
40
|
// The header is present
|
|
49
41
|
expect(
|
|
50
42
|
screen.getByRole("heading", { name: "Hello World!" })
|
|
51
43
|
).toBeInTheDocument();
|
|
52
44
|
|
|
53
|
-
expect(
|
|
54
|
-
await screen.findByText("item one", { selector: "li" })
|
|
55
|
-
).toBeInTheDocument();
|
|
45
|
+
expect(await screen.findByText("item one")).toBeInTheDocument();
|
|
56
46
|
|
|
57
|
-
expect(
|
|
58
|
-
await screen.findByText("item two", { selector: "li" })
|
|
59
|
-
).toBeInTheDocument();
|
|
47
|
+
expect(await screen.findByText("item two")).toBeInTheDocument();
|
|
60
48
|
|
|
61
49
|
// Type some data into the input
|
|
62
50
|
await userEvent.type(screen.getByLabelText("Item"), "item three");
|
|
@@ -67,7 +55,5 @@ test("<App/>", async () => {
|
|
|
67
55
|
});
|
|
68
56
|
|
|
69
57
|
// Now we should have a list item with the text we entered
|
|
70
|
-
expect(
|
|
71
|
-
await screen.findByText("item three", { selector: "li" })
|
|
72
|
-
).toBeInTheDocument();
|
|
58
|
+
expect(await screen.findByText("item three")).toBeInTheDocument();
|
|
73
59
|
});
|
package/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useContext, useEffect } from "react";
|
|
1
|
+
import React, { useState, useContext, useEffect } from "react";
|
|
2
2
|
import "./App.less";
|
|
3
3
|
import { SessionContext } from "./Session";
|
|
4
4
|
import Header from "./Header";
|
|
@@ -6,7 +6,7 @@ import Input from "./Input";
|
|
|
6
6
|
import Login from "./Login";
|
|
7
7
|
import Register from "./Register";
|
|
8
8
|
|
|
9
|
-
export type
|
|
9
|
+
export type ErrorResponse = {
|
|
10
10
|
message: string;
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -15,39 +15,67 @@ export type FormErrors = {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export default function App() {
|
|
18
|
-
const [
|
|
18
|
+
const [loaded, setLoaded] = useState<boolean>(false);
|
|
19
19
|
const [items, setItems] = useState<string[]>([]);
|
|
20
|
+
const [error, setError] = useState<string | boolean>(false);
|
|
21
|
+
|
|
22
|
+
const { session, setSession } = useContext(SessionContext);
|
|
20
23
|
|
|
21
|
-
const
|
|
24
|
+
const catchError = (err: Error) => {
|
|
25
|
+
if (err.message === "Unauthorized") {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
setError(err.message);
|
|
29
|
+
};
|
|
22
30
|
|
|
23
|
-
const itemsJson = JSON.stringify(items);
|
|
24
31
|
useEffect(() => {
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
if (loaded) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fetchApi<string[], null>("/api/items", session?.token || "")
|
|
37
|
+
.then((items) => {
|
|
38
|
+
setLoaded(true);
|
|
27
39
|
setItems(items);
|
|
28
40
|
})
|
|
29
|
-
.catch(
|
|
30
|
-
}
|
|
41
|
+
.catch(catchError);
|
|
42
|
+
});
|
|
31
43
|
|
|
32
|
-
const
|
|
33
|
-
|
|
44
|
+
const saveItem = (item: string) => {
|
|
45
|
+
fetchApi<string[], string>("/api/items", session?.token || "", "post", item)
|
|
46
|
+
.then((items) => {
|
|
47
|
+
setItems(items);
|
|
48
|
+
})
|
|
49
|
+
.catch(catchError);
|
|
50
|
+
};
|
|
34
51
|
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
const deleteItem = (item: string) => {
|
|
53
|
+
fetchApi<string[], string>(
|
|
54
|
+
"/api/items/" + item,
|
|
55
|
+
session?.token || "",
|
|
56
|
+
"delete"
|
|
57
|
+
)
|
|
58
|
+
.then((items) => {
|
|
37
59
|
setItems(items);
|
|
38
60
|
})
|
|
39
|
-
.catch(
|
|
61
|
+
.catch(catchError);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const logout = () => {
|
|
65
|
+
setSession({ token: null, user: null });
|
|
40
66
|
};
|
|
41
67
|
|
|
42
68
|
return (
|
|
43
69
|
<>
|
|
44
70
|
<Header />
|
|
71
|
+
<ErrorMessage error={error} />
|
|
45
72
|
{!session.user && (
|
|
46
73
|
<Row>
|
|
47
74
|
<Column>
|
|
48
75
|
<h2>Login</h2>
|
|
49
76
|
<Login />
|
|
50
77
|
</Column>
|
|
78
|
+
<Column />
|
|
51
79
|
<Column>
|
|
52
80
|
<h2>Register</h2>
|
|
53
81
|
<Register />
|
|
@@ -57,63 +85,128 @@ export default function App() {
|
|
|
57
85
|
{session.user && (
|
|
58
86
|
<>
|
|
59
87
|
<p>
|
|
60
|
-
<em>You logged in as {session.user?.username}
|
|
88
|
+
<em>You are logged in as {session.user?.username}.</em>{" "}
|
|
89
|
+
<span style={{ cursor: "pointer" }} onClick={logout}>
|
|
90
|
+
(logout)
|
|
91
|
+
</span>
|
|
61
92
|
</p>
|
|
62
|
-
<
|
|
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>
|
|
93
|
+
<ItemList items={items} saveItem={saveItem} deleteItem={deleteItem} />
|
|
75
94
|
</>
|
|
76
95
|
)}
|
|
96
|
+
<Spacer />
|
|
77
97
|
</>
|
|
78
98
|
);
|
|
79
99
|
}
|
|
80
100
|
|
|
81
|
-
function
|
|
101
|
+
function ItemList({
|
|
102
|
+
items,
|
|
103
|
+
saveItem,
|
|
104
|
+
deleteItem,
|
|
105
|
+
}: {
|
|
106
|
+
items: string[];
|
|
107
|
+
saveItem(item: string): void;
|
|
108
|
+
deleteItem(item: string): void;
|
|
109
|
+
}) {
|
|
110
|
+
const [value, setValue] = useState<string>("");
|
|
111
|
+
|
|
112
|
+
const addItem = () => {
|
|
113
|
+
saveItem(value);
|
|
114
|
+
setValue("");
|
|
115
|
+
};
|
|
116
|
+
|
|
82
117
|
return (
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
<>
|
|
119
|
+
<h2>New Item</h2>
|
|
120
|
+
<Input
|
|
121
|
+
label="Item"
|
|
122
|
+
name="item"
|
|
123
|
+
value={value}
|
|
124
|
+
onChange={(value) => setValue(value)}
|
|
125
|
+
onEnter={addItem}
|
|
126
|
+
/>
|
|
127
|
+
<button onClick={addItem}>Add</button>
|
|
128
|
+
<Spacer />
|
|
129
|
+
<h2>My Items</h2>
|
|
130
|
+
<ul style={{ padding: 0 }}>
|
|
131
|
+
{items.map((item, i) => (
|
|
132
|
+
<Row key={i} as="li">
|
|
133
|
+
<button
|
|
134
|
+
style={{ marginLeft: "auto", order: 2 }}
|
|
135
|
+
onClick={() => deleteItem(item)}
|
|
136
|
+
>
|
|
137
|
+
Delete
|
|
138
|
+
</button>
|
|
139
|
+
<div>{item}</div>
|
|
140
|
+
</Row>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function Row({
|
|
148
|
+
as = "div",
|
|
149
|
+
children,
|
|
150
|
+
}: {
|
|
151
|
+
as?: keyof JSX.IntrinsicElements;
|
|
152
|
+
children?: React.ReactNode;
|
|
153
|
+
}) {
|
|
154
|
+
return React.createElement(
|
|
155
|
+
as,
|
|
156
|
+
{
|
|
157
|
+
style: {
|
|
85
158
|
display: "flex",
|
|
86
159
|
flexDirection: "row",
|
|
87
160
|
flexWrap: "wrap",
|
|
88
161
|
width: "100%",
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
</div>
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
children
|
|
93
165
|
);
|
|
94
166
|
}
|
|
95
167
|
|
|
96
|
-
function
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
168
|
+
export function Spacer({ height = "2em" }: { height?: string | number }) {
|
|
169
|
+
return <div style={{ height, minHeight: height }} />;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function Column({
|
|
173
|
+
as = "div",
|
|
174
|
+
children,
|
|
175
|
+
}: {
|
|
176
|
+
as?: keyof JSX.IntrinsicElements;
|
|
177
|
+
children?: React.ReactNode;
|
|
178
|
+
}) {
|
|
179
|
+
return React.createElement(
|
|
180
|
+
as,
|
|
181
|
+
{
|
|
182
|
+
style: {
|
|
100
183
|
display: "flex",
|
|
101
184
|
flexDirection: "column",
|
|
102
185
|
flexBasis: "100%",
|
|
103
|
-
flex: 1,
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
</div>
|
|
186
|
+
flex: "1 1 0",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
children
|
|
108
190
|
);
|
|
109
191
|
}
|
|
110
192
|
|
|
111
|
-
function
|
|
193
|
+
export function ErrorMessage({ error }: { error: string | boolean }) {
|
|
194
|
+
if (error) {
|
|
195
|
+
return (
|
|
196
|
+
<p>
|
|
197
|
+
<strong>An error has occurred:</strong> {error}
|
|
198
|
+
</p>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return <></>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function fetchApi<T, P>(
|
|
112
205
|
url: string,
|
|
113
206
|
token: string,
|
|
114
207
|
method = "get",
|
|
115
|
-
data?:
|
|
116
|
-
): Promise<
|
|
208
|
+
data?: P
|
|
209
|
+
): Promise<T> {
|
|
117
210
|
return fetch(url, {
|
|
118
211
|
method: method,
|
|
119
212
|
headers: {
|
|
@@ -122,12 +215,10 @@ function fetchApi(
|
|
|
122
215
|
"Content-Type": "application/json",
|
|
123
216
|
},
|
|
124
217
|
body: JSON.stringify(data),
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
})
|
|
132
|
-
.then((response) => response.json());
|
|
218
|
+
}).then((response) => {
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
throw new Error(response.statusText);
|
|
221
|
+
}
|
|
222
|
+
return response.json() as Promise<T>;
|
|
223
|
+
});
|
|
133
224
|
}
|
package/src/Login.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useContext } from "react";
|
|
2
|
-
import {
|
|
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
|
|
47
|
+
setError((data as ErrorResponse).message);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
)
|
|
51
|
-
.catch((err) => {
|
|
52
|
-
|
|
51
|
+
.catch((err: Error) => {
|
|
52
|
+
setError(err.message);
|
|
53
53
|
});
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
return (
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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>
|