@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 +22 -2
- package/package.json +11 -7
- package/src/App.test.tsx +65 -58
- package/src/App.tsx +60 -211
- package/src/Session.tsx +124 -49
- package/src/components/Column.tsx +24 -0
- package/src/components/ErrorMessage.tsx +16 -0
- package/src/components/Header.jsx +14 -0
- package/src/{Input.tsx → components/Input.tsx} +10 -7
- package/src/components/Row.tsx +24 -0
- package/src/components/Spacer.tsx +5 -0
- package/src/components/SuccessMessage.tsx +12 -0
- package/src/components/index.tsx +7 -0
- package/src/helpers/fetchApi.tsx +73 -0
- package/src/index.tsx +2 -0
- package/src/views/Account.test.tsx +87 -0
- package/src/views/Account.tsx +22 -0
- package/src/views/Items.test.tsx +53 -0
- package/src/views/Items.tsx +84 -0
- package/src/views/Login.tsx +57 -0
- package/src/views/Password.tsx +89 -0
- package/src/views/Profile.tsx +66 -0
- package/src/views/SignUp.tsx +82 -0
- package/src/views/Verify.tsx +24 -0
- package/src/views/index.tsx +7 -0
- package/src/Header.jsx +0 -4
- package/src/Login.tsx +0 -82
- package/src/Register.tsx +0 -82
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
|
-
|
|
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 =
|
|
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.
|
|
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:
|
|
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-
|
|
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.
|
|
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
|
|
2
|
-
import
|
|
3
|
-
import App
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
const [
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
// After 10 seconds, clear out any messages
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
setMessage(null);
|
|
31
|
+
}, 1000 * 10);
|
|
42
32
|
|
|
43
|
-
|
|
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
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
{
|
|
63
|
+
{user && (
|
|
100
64
|
<>
|
|
101
65
|
<p>
|
|
102
|
-
<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
|
-
<
|
|
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
|
-
}
|