@stanlemon/app-template 0.3.19 → 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/src/Session.tsx CHANGED
@@ -1,66 +1,103 @@
1
- import React, { useState, useEffect, createContext } from "react";
2
- import { ErrorMessage } from "./App";
3
-
4
- export const SessionContext = createContext<{
5
- session: SessionData;
6
- setSession: React.Dispatch<React.SetStateAction<SessionData>>;
7
- }>({
8
- session: {
9
- token: null,
10
- user: null,
11
- },
12
- setSession: () => {},
13
- });
1
+ import React, { useState, useEffect, useContext, createContext } from "react";
2
+ import { useCookies } from "react-cookie";
3
+ import { ErrorMessage } from "./components/";
4
+ import fetchApi from "./helpers/fetchApi";
5
+
6
+ type SessionContextProperties = {
7
+ initialized: boolean;
8
+ token: string | null;
9
+ user: ProfileData | null;
10
+ error: string | null;
11
+ message: string | null;
12
+ };
13
+
14
+ export type SessionContextData = SessionContextProperties & {
15
+ setInitialized: React.Dispatch<React.SetStateAction<boolean>>;
16
+ setToken: React.Dispatch<React.SetStateAction<string | null>>;
17
+ setUser: (user: ProfileData | null) => void;
18
+ setError: React.Dispatch<React.SetStateAction<string | null>>;
19
+ setMessage: React.Dispatch<React.SetStateAction<string | null>>;
20
+ };
21
+
22
+ const DEFAULT_SESSION_CONTEXT_DATA: SessionContextData = {
23
+ initialized: false,
24
+ setInitialized: () => {},
25
+ token: null,
26
+ setToken: () => {},
27
+ user: null,
28
+ setUser: () => {},
29
+ error: null,
30
+ setError: () => {},
31
+ message: null,
32
+ setMessage: () => {},
33
+ };
34
+
35
+ export const SessionContext = createContext<SessionContextData>(
36
+ DEFAULT_SESSION_CONTEXT_DATA
37
+ );
14
38
 
15
39
  export type SessionData = {
16
40
  token: string | null;
17
- user: UserData | null;
41
+ user: ProfileData | null;
18
42
  };
19
43
 
20
- export type UserData = {
21
- name: string | null;
22
- email: string | null;
44
+ export type ProfileData = {
23
45
  username: string;
24
- password: string;
46
+ name: string;
47
+ email: string;
25
48
  };
26
49
 
27
50
  export default function Session({ children }: { children: React.ReactNode }) {
28
- const [initialized, setInitialized] = useState<boolean>(false);
29
- const [error, setError] = useState<string | boolean>(false);
30
- const [session, setSession] = useState<SessionData>({
31
- token: null,
32
- user: null,
33
- });
51
+ return (
52
+ <SessionAware>
53
+ <SessionLoader>{children}</SessionLoader>
54
+ </SessionAware>
55
+ );
56
+ }
34
57
 
35
- useEffect(() => {
36
- fetch("/auth/session", {
37
- headers: {
38
- Authorization: `Bearer ${session.token || ""}`,
39
- Accept: "application/json",
40
- "Content-Type": "application/json",
41
- },
42
- })
43
- .then((response) => {
44
- setInitialized(true);
58
+ export function SessionLoader({ children }: { children: React.ReactNode }) {
59
+ const {
60
+ initialized,
61
+ setInitialized,
62
+ token,
63
+ setToken,
64
+ setUser,
65
+ error,
66
+ setError,
67
+ } = useContext(SessionContext);
68
+ const [cookies, setCookie] = useCookies(["session_token"]);
45
69
 
46
- if (!response.ok) {
47
- throw new ErrorMessage(response.statusText);
48
- }
49
- return response;
50
- })
51
- .then((response) => response.json())
70
+ const checkSession = () => {
71
+ fetchApi<SessionData, null>("/auth/session", token || cookies.session_token)
52
72
  .then((session: SessionData) => {
53
- setSession(session);
73
+ if (session) {
74
+ setCookie("session_token", session.token, { path: "/" });
75
+ setToken(session.token);
76
+ setUser(session.user);
77
+ }
54
78
  })
55
- .catch((err: Error) => {
56
- if (err.message === "Unauthorized") {
57
- return;
79
+ .catch((err) => {
80
+ if (err.message !== "Unauthorized") {
81
+ setError(err.message);
58
82
  }
59
- setError(err.message);
83
+ })
84
+ .finally(() => {
85
+ setInitialized(true);
60
86
  });
61
- }, [session?.token, initialized]);
87
+ };
62
88
 
63
- if (error) {
89
+ useEffect(() => {
90
+ if (!initialized) {
91
+ checkSession();
92
+ }
93
+
94
+ // Refresh the session every 30 seconds
95
+ const intervalId = setInterval(checkSession, 1000 * 30);
96
+
97
+ return () => clearInterval(intervalId);
98
+ }, [token]);
99
+
100
+ if (!initialized && error) {
64
101
  return <ErrorMessage error={error} />;
65
102
  }
66
103
 
@@ -72,10 +109,48 @@ export default function Session({ children }: { children: React.ReactNode }) {
72
109
  );
73
110
  }
74
111
 
75
- const contextValue = { session, setSession };
112
+ return children;
113
+ }
114
+
115
+ export function SessionAware({
116
+ initialized: defaultInitialized = false,
117
+ token: defaultToken = null,
118
+ user: defaultUser = null,
119
+ error: defaultError = null,
120
+ message: defaultMessage = null,
121
+ children,
122
+ }: Partial<SessionContextProperties> & { children: React.ReactNode }) {
123
+ const [initialized, setInitialized] = useState<boolean>(defaultInitialized);
124
+ const [token, setToken] = useState<string | null>(defaultToken);
125
+ const [user, setUser] = useState<ProfileData | null>(defaultUser);
126
+ const [error, setError] = useState<string | null>(defaultError);
127
+ const [message, setMessage] = useState<string | null>(defaultMessage);
76
128
 
77
129
  return (
78
- <SessionContext.Provider value={contextValue}>
130
+ <SessionContext.Provider
131
+ value={{
132
+ initialized,
133
+ setInitialized,
134
+ token,
135
+ setToken,
136
+ user,
137
+ // Allow for partial setting of user data
138
+ setUser: (user: ProfileData | null) => {
139
+ if (!user) {
140
+ setUser(null);
141
+ return;
142
+ }
143
+ setUser((oldUser: React.SetStateAction<ProfileData | null>) => ({
144
+ ...oldUser,
145
+ ...user,
146
+ }));
147
+ },
148
+ error,
149
+ setError,
150
+ message,
151
+ setMessage,
152
+ }}
153
+ >
79
154
  {children}
80
155
  </SessionContext.Provider>
81
156
  );
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+
3
+ export function Column({
4
+ as = "div",
5
+ children,
6
+ }: {
7
+ as?: keyof React.JSX.IntrinsicElements;
8
+ children?: React.ReactNode;
9
+ }) {
10
+ return React.createElement(
11
+ as,
12
+ {
13
+ style: {
14
+ display: "flex",
15
+ flexDirection: "column",
16
+ flexBasis: "100%",
17
+ flex: "1 1 0",
18
+ },
19
+ },
20
+ children
21
+ );
22
+ }
23
+
24
+ export default Column;
@@ -0,0 +1,16 @@
1
+ export function ErrorMessage({
2
+ error,
3
+ }: {
4
+ error: string | boolean | undefined | null;
5
+ }) {
6
+ if (error) {
7
+ return (
8
+ <p style={{ color: "red" }}>
9
+ <strong>An error has occurred:</strong> {error}
10
+ </p>
11
+ );
12
+ }
13
+ return <></>;
14
+ }
15
+
16
+ export default ErrorMessage;
@@ -0,0 +1,14 @@
1
+ // While typescript is preferred, you can also use good ol javascript too!
2
+ /**
3
+ *
4
+ * @param {Object} props
5
+ * @param {number} [props.level]
6
+ * @param {React.ReactNode} [props.children]
7
+ * @returns {JSX.Element}
8
+ */
9
+ export function Header({ level = 1, children = "Hello World!" }) {
10
+ const Tag = `h${level}`;
11
+ return <Tag>{children}</Tag>;
12
+ }
13
+
14
+ export default Header;
@@ -1,3 +1,4 @@
1
+ import React from "react";
1
2
  export interface Props
2
3
  // Inherit everything from input except onChange which we simplify here
3
4
  extends Omit<React.ComponentPropsWithRef<"input">, "onChange"> {
@@ -8,10 +9,10 @@ export interface Props
8
9
  placeholder?: string;
9
10
  onChange?: (value: string) => void;
10
11
  onEnter?: () => void;
11
- error?: string;
12
+ error?: string | null | undefined;
12
13
  }
13
14
 
14
- export default function Input({
15
+ export function Input({
15
16
  type = "text",
16
17
  name,
17
18
  value = "",
@@ -29,7 +30,7 @@ export default function Input({
29
30
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
30
31
  onChange(e.currentTarget.value);
31
32
  };
32
- const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
33
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
33
34
  if (e.key === "Enter") {
34
35
  e.preventDefault();
35
36
  onEnter();
@@ -37,18 +38,20 @@ export default function Input({
37
38
  };
38
39
 
39
40
  return (
40
- <>
41
+ <div style={{ marginBottom: ".75em" }}>
41
42
  {label && <label htmlFor={name}>{label}</label>}
42
43
  <input
43
44
  type={type}
44
45
  id={name}
45
46
  onChange={handleChange}
46
- onKeyPress={handleKeyPress}
47
+ onKeyDown={handleKeyDown}
47
48
  placeholder={placeholder}
48
49
  value={value}
49
50
  {...attrs}
50
51
  />
51
- {error && <div>{error}</div>}
52
- </>
52
+ {error && <div style={{ color: "red" }}>{error}</div>}
53
+ </div>
53
54
  );
54
55
  }
56
+
57
+ export default Input;
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+
3
+ export function Row({
4
+ as = "div",
5
+ children,
6
+ }: {
7
+ as?: keyof React.JSX.IntrinsicElements;
8
+ children?: React.ReactNode;
9
+ }) {
10
+ return React.createElement(
11
+ as,
12
+ {
13
+ style: {
14
+ display: "flex",
15
+ flexDirection: "row",
16
+ flexWrap: "wrap",
17
+ width: "100%",
18
+ },
19
+ },
20
+ children
21
+ );
22
+ }
23
+
24
+ export default Row;
@@ -0,0 +1,5 @@
1
+ export function Spacer({ height = "2em" }: { height?: string | number }) {
2
+ return <div style={{ height, minHeight: height }} />;
3
+ }
4
+
5
+ export default Spacer;
@@ -0,0 +1,12 @@
1
+ export function SuccessMessage({
2
+ message,
3
+ }: {
4
+ message: string | boolean | undefined | null;
5
+ }) {
6
+ if (message) {
7
+ return <p style={{ color: "green" }}>{message}</p>;
8
+ }
9
+ return <></>;
10
+ }
11
+
12
+ export default SuccessMessage;
@@ -0,0 +1,7 @@
1
+ export * from "./Column";
2
+ export * from "./ErrorMessage";
3
+ export * from "./Header";
4
+ export * from "./Input";
5
+ export * from "./Row";
6
+ export * from "./Spacer";
7
+ export * from "./SuccessMessage";
@@ -0,0 +1,73 @@
1
+ export function fetchApi<T, P>(
2
+ url: string,
3
+ token: string | null | undefined = null,
4
+ method = "get",
5
+ data?: P
6
+ ): Promise<T> {
7
+ return (
8
+ fetch(url, {
9
+ method: method,
10
+ headers: {
11
+ Accept: "application/json",
12
+ "Content-Type": "application/json",
13
+ // Only send the authorization header if we have a bearer token
14
+ ...(token ? { Authorization: `Bearer ${token ?? ""}` } : {}),
15
+ },
16
+ body: JSON.stringify(data),
17
+ })
18
+ // This allows us to have both the status code and the body
19
+ .then((response) =>
20
+ response.text().then((body) => ({
21
+ ok: response.ok,
22
+ status: response.status,
23
+ statusText: response.statusText,
24
+ body,
25
+ }))
26
+ )
27
+ .then(({ ok, status, statusText, body }) => {
28
+ if (!ok) {
29
+ throw new ApiError(status, statusText, quietJSONParse(body));
30
+ }
31
+
32
+ return JSON.parse(body) as T;
33
+ })
34
+ );
35
+ }
36
+ /**
37
+ * Parse JSON and eat any parse errors.
38
+ * @param body text
39
+ * @returns json or null
40
+ */
41
+ function quietJSONParse<T>(body: string): T | null {
42
+ try {
43
+ return JSON.parse(body);
44
+ } catch (err) {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export default fetchApi;
50
+
51
+ export class ApiError extends Error {
52
+ #code: number;
53
+
54
+ get code(): number {
55
+ return this.#code;
56
+ }
57
+
58
+ #body: Record<string, unknown>;
59
+
60
+ get body(): Record<string, unknown> {
61
+ return this.#body;
62
+ }
63
+
64
+ constructor(
65
+ code: number,
66
+ message: string,
67
+ body: Record<string, unknown> | null
68
+ ) {
69
+ super(message);
70
+ this.#code = code;
71
+ this.#body = body ?? {};
72
+ }
73
+ }
package/src/index.tsx CHANGED
@@ -2,6 +2,8 @@ import { createRoot } from "react-dom/client";
2
2
  import App from "./App";
3
3
  import Session from "./Session";
4
4
 
5
+ document.title = "App";
6
+
5
7
  const root = createRoot(
6
8
  document.body.appendChild(document.createElement("div"))
7
9
  );
@@ -0,0 +1,87 @@
1
+ import "@testing-library/jest-dom";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { Account, PasswordRequest, ProfileForm } from "./";
5
+ import { SessionAware } from "../Session";
6
+ import fetchApi from "../helpers/fetchApi";
7
+
8
+ jest.mock("../helpers/fetchApi");
9
+
10
+ describe("<Account/>", () => {
11
+ beforeEach(() => {
12
+ jest.resetAllMocks();
13
+ });
14
+
15
+ it("profile updated", async () => {
16
+ const mockedFetchApi = fetchApi as jest.MockedFunction<
17
+ typeof fetchApi<ProfileForm, ProfileForm>
18
+ >;
19
+ mockedFetchApi.mockResolvedValue({
20
+ name: "Jean Luc Picard",
21
+ email: "jeanluc@starfleet.com",
22
+ });
23
+
24
+ render(
25
+ <SessionAware>
26
+ <Account />
27
+ </SessionAware>
28
+ );
29
+
30
+ expect(screen.getByText('Account for ""')).toBeInTheDocument();
31
+ expect(screen.getByText("Profile")).toBeInTheDocument();
32
+ expect(screen.getByText("Password")).toBeInTheDocument();
33
+
34
+ await userEvent.type(screen.getByLabelText("Name"), "Jean Luc Picard");
35
+
36
+ fireEvent.click(screen.getByText("Save", { selector: "button" }));
37
+
38
+ await waitFor(() => {
39
+ expect(
40
+ screen.getByText('Account for "Jean Luc Picard"')
41
+ ).toBeInTheDocument();
42
+ });
43
+ });
44
+
45
+ it("password changed", async () => {
46
+ const mockedFetchApi = fetchApi as jest.MockedFunction<
47
+ typeof fetchApi<null, PasswordRequest>
48
+ >;
49
+ mockedFetchApi.mockResolvedValue(null);
50
+
51
+ render(
52
+ <SessionAware>
53
+ <Account />
54
+ </SessionAware>
55
+ );
56
+
57
+ expect(screen.getByText('Account for ""')).toBeInTheDocument();
58
+ expect(screen.getByText("Profile")).toBeInTheDocument();
59
+ expect(screen.getByText("Password")).toBeInTheDocument();
60
+
61
+ const current_password = "P@ssw0rd1";
62
+ const password = "P@ssw0rd2";
63
+
64
+ await userEvent.type(
65
+ screen.getByLabelText("Current Password"),
66
+ current_password
67
+ );
68
+
69
+ await userEvent.type(screen.getByLabelText("New Password"), password);
70
+
71
+ await userEvent.type(
72
+ screen.getByLabelText("Repeat New Password"),
73
+ password
74
+ );
75
+
76
+ fireEvent.click(screen.getByText("Update", { selector: "button" }));
77
+
78
+ await waitFor(() => {
79
+ expect(mockedFetchApi.mock.calls).toHaveLength(1);
80
+ });
81
+
82
+ expect(mockedFetchApi.mock.calls[0][3]).toEqual({
83
+ current_password,
84
+ password,
85
+ });
86
+ });
87
+ });
@@ -0,0 +1,22 @@
1
+ import { useContext } from "react";
2
+ import { SessionContext } from "../Session";
3
+ import { Header, Spacer } from "../components";
4
+ import { Profile, Password } from "./";
5
+ import { Link } from "wouter";
6
+
7
+ export function Account() {
8
+ const { user } = useContext(SessionContext);
9
+
10
+ return (
11
+ <>
12
+ <Header>Account for "{user?.name ?? user?.username ?? ""}"</Header>
13
+ <Profile />
14
+ <Spacer />
15
+ <Password />
16
+ <Spacer />
17
+ <Link href="/">Return Home</Link>
18
+ </>
19
+ );
20
+ }
21
+
22
+ export default Account;
@@ -0,0 +1,53 @@
1
+ import "@testing-library/jest-dom";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import Items, { ItemData } from "./Items";
5
+ import { SessionAware } from "../Session";
6
+ import fetchApi from "../helpers/fetchApi";
7
+
8
+ jest.mock("../helpers/fetchApi");
9
+
10
+ describe("<Items/>", () => {
11
+ beforeEach(() => {
12
+ jest.resetAllMocks();
13
+ });
14
+
15
+ it("renders", async () => {
16
+ const output = [
17
+ { id: "1", item: "item one" },
18
+ { id: "2", item: "item two" },
19
+ ];
20
+
21
+ const mockedFetchApi = fetchApi as jest.MockedFunction<
22
+ typeof fetchApi<ItemData[], null>
23
+ >;
24
+ mockedFetchApi.mockResolvedValue(output);
25
+
26
+ render(
27
+ <SessionAware>
28
+ <Items />
29
+ </SessionAware>
30
+ );
31
+
32
+ expect(await screen.findByText("item one")).toBeInTheDocument();
33
+
34
+ expect(await screen.findByText("item two")).toBeInTheDocument();
35
+
36
+ // Type some data into the input
37
+ await userEvent.type(screen.getByLabelText("Item"), "item three");
38
+
39
+ // This is the response that will be provided on the add call
40
+ mockedFetchApi.mockResolvedValue([
41
+ ...output,
42
+ { id: "3", item: "item three" },
43
+ ]);
44
+
45
+ // Click the add button
46
+ fireEvent.click(screen.getByText("Add", { selector: "button" }));
47
+
48
+ expect(mockedFetchApi.mock.calls[1][3]).toEqual({ item: "item three" });
49
+
50
+ // Now we should have a list item with the text we entered
51
+ expect(await screen.findByText("item three")).toBeInTheDocument();
52
+ });
53
+ });