create-react-starter-kit 1.0.5 → 1.0.7
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/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +2 -1
- package/dist/cli-args.js.map +1 -1
- package/dist/generator/dependencies.d.ts.map +1 -1
- package/dist/generator/dependencies.js +5 -0
- package/dist/generator/dependencies.js.map +1 -1
- package/dist/generator/index.d.ts.map +1 -1
- package/dist/generator/index.js +5 -0
- package/dist/generator/index.js.map +1 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +8 -0
- package/dist/prompts/index.js.map +1 -1
- package/dist/templates/README.md.ejs +1 -1
- package/dist/templates/__when_apiLayer_axios__/src/services/api.ts.ejs +23 -0
- package/dist/templates/__when_apiLayer_axios__/src/services/http/client.ts.ejs +174 -0
- package/dist/templates/__when_apiLayer_axios__/src/services/http/errors.ts.ejs +81 -0
- package/dist/templates/__when_apiLayer_axios__/src/services/http/index.ts.ejs +5 -0
- package/dist/templates/__when_apiLayer_fetch__/src/services/api.ts.ejs +22 -0
- package/dist/templates/__when_apiLayer_fetch__/src/services/http/client.ts.ejs +195 -0
- package/dist/templates/__when_apiLayer_fetch__/src/services/http/errors.ts.ejs +81 -0
- package/dist/templates/__when_apiLayer_fetch__/src/services/http/index.ts.ejs +5 -0
- package/dist/templates/__when_buildTool_vite__/vite.config.ts.ejs +9 -1
- package/dist/templates/__when_buildTool_webpack__/webpack.config.js.ejs +1 -1
- package/dist/templates/__when_e2eTesting_cypress__/cypress.config.ts.ejs +2 -1
- package/dist/templates/__when_e2eTesting_playwright__/playwright.config.ts.ejs +4 -3
- package/dist/templates/__when_serverState_rtk-query__/src/store/postsApi.ts.ejs +28 -5
- package/dist/templates/__when_unitTesting_jest__/jest.config.json.ejs +8 -0
- package/dist/templates/__when_unitTesting_vitest__/tests/feedback.test.tsx.ejs +16 -0
- package/dist/templates/__when_unitTesting_vitest__/tests/posts.test.tsx.ejs +1 -3
- package/dist/templates/__when_unitTesting_vitest__/vitest.config.ts.ejs +31 -0
- package/dist/templates/docs/architecture.md.ejs +44 -25
- package/dist/templates/docs/best-practices.md.ejs +39 -3
- package/dist/templates/docs/examples/README.md.ejs +10 -10
- package/dist/templates/docs/examples/api.md.ejs +165 -8
- package/dist/templates/docs/examples/data-fetching.md.ejs +107 -6
- package/dist/templates/docs/examples/forms.md.ejs +66 -6
- package/dist/templates/docs/examples/state.md.ejs +50 -6
- package/dist/templates/docs/examples/styling.md.ejs +56 -8
- package/dist/templates/docs/examples/testing.md.ejs +63 -9
- package/dist/templates/docs/getting-started.md.ejs +63 -15
- package/dist/templates/docs/selected-stack.md.ejs +110 -8
- package/dist/templates/src/features/feedback/FeedbackForm.tsx.ejs +10 -0
- package/dist/templates/src/features/posts/PostsPanel.tsx.ejs +1 -1
- package/dist/templates/src/routes/PostsPage.tsx.ejs +7 -2
- package/dist/templates/src/types/index.ts.ejs +1 -1
- package/dist/templates/src/types/models.ts.ejs +18 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/README.md.ejs +1 -1
- package/templates/__when_apiLayer_axios__/src/services/api.ts.ejs +23 -0
- package/templates/__when_apiLayer_axios__/src/services/http/client.ts.ejs +174 -0
- package/templates/__when_apiLayer_axios__/src/services/http/errors.ts.ejs +81 -0
- package/templates/__when_apiLayer_axios__/src/services/http/index.ts.ejs +5 -0
- package/templates/__when_apiLayer_fetch__/src/services/api.ts.ejs +22 -0
- package/templates/__when_apiLayer_fetch__/src/services/http/client.ts.ejs +195 -0
- package/templates/__when_apiLayer_fetch__/src/services/http/errors.ts.ejs +81 -0
- package/templates/__when_apiLayer_fetch__/src/services/http/index.ts.ejs +5 -0
- package/templates/__when_buildTool_vite__/vite.config.ts.ejs +9 -1
- package/templates/__when_buildTool_webpack__/webpack.config.js.ejs +1 -1
- package/templates/__when_e2eTesting_cypress__/cypress.config.ts.ejs +2 -1
- package/templates/__when_e2eTesting_playwright__/playwright.config.ts.ejs +4 -3
- package/templates/__when_serverState_rtk-query__/src/store/postsApi.ts.ejs +28 -5
- package/templates/__when_unitTesting_jest__/jest.config.json.ejs +8 -0
- package/templates/__when_unitTesting_vitest__/tests/feedback.test.tsx.ejs +16 -0
- package/templates/__when_unitTesting_vitest__/tests/posts.test.tsx.ejs +1 -3
- package/templates/__when_unitTesting_vitest__/vitest.config.ts.ejs +31 -0
- package/templates/docs/architecture.md.ejs +44 -25
- package/templates/docs/best-practices.md.ejs +39 -3
- package/templates/docs/examples/README.md.ejs +10 -10
- package/templates/docs/examples/api.md.ejs +165 -8
- package/templates/docs/examples/data-fetching.md.ejs +107 -6
- package/templates/docs/examples/forms.md.ejs +66 -6
- package/templates/docs/examples/state.md.ejs +50 -6
- package/templates/docs/examples/styling.md.ejs +56 -8
- package/templates/docs/examples/testing.md.ejs +63 -9
- package/templates/docs/getting-started.md.ejs +63 -15
- package/templates/docs/selected-stack.md.ejs +110 -8
- package/templates/src/features/feedback/FeedbackForm.tsx.ejs +10 -0
- package/templates/src/features/posts/PostsPanel.tsx.ejs +1 -1
- package/templates/src/routes/PostsPage.tsx.ejs +7 -2
- package/templates/src/types/index.ts.ejs +1 -1
- package/templates/src/types/models.ts.ejs +18 -0
- package/dist/templates/src/services/api.ts.ejs +0 -84
- package/templates/src/services/api.ts.ejs +0 -84
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { env } from '@/config/env';
|
|
2
|
+
import { ApiError, mapStatusToCode } from './errors';
|
|
3
|
+
|
|
4
|
+
const REQUEST_ID_HEADER = 'X-Request-Id';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
6
|
+
|
|
7
|
+
<% if (isTypeScript) { -%>
|
|
8
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
9
|
+
|
|
10
|
+
export interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
|
|
11
|
+
method?: HttpMethod;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Skip JSON Content-Type / body serialization when sending FormData, etc. */
|
|
15
|
+
rawBody?: boolean;
|
|
16
|
+
}
|
|
17
|
+
<% } -%>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Swap this for your real auth storage (cookie, memory store, secure storage).
|
|
21
|
+
*/
|
|
22
|
+
function getAccessToken()<% if (isTypeScript) { %>: string | null<% } %> {
|
|
23
|
+
if (typeof window === 'undefined') return null;
|
|
24
|
+
return window.localStorage.getItem('access_token');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createRequestId()<% if (isTypeScript) { %>: string<% } %> {
|
|
28
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
29
|
+
return crypto.randomUUID();
|
|
30
|
+
}
|
|
31
|
+
return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function parseErrorBody(response<% if (isTypeScript) { %>: Response<% } %>)<% if (isTypeScript) { %>: Promise<unknown><% } %> {
|
|
35
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
36
|
+
if (contentType.includes('application/json')) {
|
|
37
|
+
try {
|
|
38
|
+
return await response.json();
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return await response.text();
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Shared fetch wrapper with auth header, request IDs, timeout, and normalized errors.
|
|
52
|
+
* Prefer domain helpers in `services/api` over calling this from components.
|
|
53
|
+
*/
|
|
54
|
+
export async function request<% if (isTypeScript) { %><T = unknown><% } %>(
|
|
55
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
56
|
+
options<% if (isTypeScript) { %>: RequestOptions = {}<% } else { %> = {}<% } %>,
|
|
57
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
58
|
+
const {
|
|
59
|
+
method = 'GET',
|
|
60
|
+
body,
|
|
61
|
+
headers: initHeaders,
|
|
62
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
63
|
+
rawBody = false,
|
|
64
|
+
signal: userSignal,
|
|
65
|
+
...rest
|
|
66
|
+
} = options;
|
|
67
|
+
|
|
68
|
+
const requestId = createRequestId();
|
|
69
|
+
const headers = new Headers(initHeaders);
|
|
70
|
+
headers.set(REQUEST_ID_HEADER, requestId);
|
|
71
|
+
headers.set('Accept', 'application/json');
|
|
72
|
+
|
|
73
|
+
const token = getAccessToken();
|
|
74
|
+
if (token) {
|
|
75
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let payload<% if (isTypeScript) { %>: BodyInit | undefined<% } %>;
|
|
79
|
+
if (body !== undefined && body !== null) {
|
|
80
|
+
if (rawBody) {
|
|
81
|
+
payload = body<% if (isTypeScript) { %> as BodyInit<% } %>;
|
|
82
|
+
} else {
|
|
83
|
+
headers.set('Content-Type', 'application/json');
|
|
84
|
+
payload = JSON.stringify(body);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const onAbort = () => controller.abort();
|
|
90
|
+
if (userSignal) {
|
|
91
|
+
if (userSignal.aborted) controller.abort();
|
|
92
|
+
else userSignal.addEventListener('abort', onAbort, { once: true });
|
|
93
|
+
}
|
|
94
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch(`${env.apiBaseUrl}${path}`, {
|
|
98
|
+
...rest,
|
|
99
|
+
method,
|
|
100
|
+
headers,
|
|
101
|
+
body: payload,
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const details = await parseErrorBody(response);
|
|
107
|
+
const message =
|
|
108
|
+
details && typeof details === 'object' && 'message' in details && typeof details.message === 'string'
|
|
109
|
+
? details.message
|
|
110
|
+
: `Request failed with status ${response.status}`;
|
|
111
|
+
|
|
112
|
+
const apiError = new ApiError({
|
|
113
|
+
message,
|
|
114
|
+
status: response.status,
|
|
115
|
+
code: mapStatusToCode(response.status),
|
|
116
|
+
details,
|
|
117
|
+
requestId,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (apiError.isUnauthorized && typeof window !== 'undefined') {
|
|
121
|
+
window.dispatchEvent(new CustomEvent('auth:unauthorized', { detail: apiError }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw apiError;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (response.status === 204) {
|
|
128
|
+
return undefined<% if (isTypeScript) { %> as T<% } %>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (await response.json())<% if (isTypeScript) { %> as T<% } %>;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof ApiError) throw error;
|
|
134
|
+
|
|
135
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
136
|
+
const timedOut = !userSignal?.aborted;
|
|
137
|
+
throw new ApiError({
|
|
138
|
+
message: timedOut ? 'Request timed out. Please try again.' : 'Request was cancelled.',
|
|
139
|
+
code: timedOut ? 'TIMEOUT' : 'NETWORK',
|
|
140
|
+
requestId,
|
|
141
|
+
cause: error,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new ApiError({
|
|
146
|
+
message: error instanceof Error ? error.message : 'Network error. Check your connection and try again.',
|
|
147
|
+
code: 'NETWORK',
|
|
148
|
+
requestId,
|
|
149
|
+
cause: error,
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timeoutId);
|
|
153
|
+
userSignal?.removeEventListener('abort', onAbort);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function apiGet<% if (isTypeScript) { %><T = unknown><% } %>(
|
|
158
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
159
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
160
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
161
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'GET' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function apiPost<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
165
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
166
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
167
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
168
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
169
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'POST', body });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function apiPut<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
173
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
174
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
175
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
176
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
177
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'PUT', body });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function apiPatch<% if (isTypeScript) { %><T = unknown, B = unknown><% } %>(
|
|
181
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
182
|
+
body<% if (isTypeScript) { %>?: B<% } %>,
|
|
183
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
184
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
185
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'PATCH', body });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function apiDelete<% if (isTypeScript) { %><T = unknown><% } %>(
|
|
189
|
+
path<% if (isTypeScript) { %>: string<% } %>,
|
|
190
|
+
options<% if (isTypeScript) { %>?: Omit<RequestOptions, 'method' | 'body'><% } %>,
|
|
191
|
+
)<% if (isTypeScript) { %>: Promise<T><% } %> {
|
|
192
|
+
return request<% if (isTypeScript) { %><T><% } %>(path, { ...options, method: 'DELETE' });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export { ApiError };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<% if (isTypeScript) { -%>
|
|
2
|
+
export type ApiErrorCode =
|
|
3
|
+
| 'NETWORK'
|
|
4
|
+
| 'TIMEOUT'
|
|
5
|
+
| 'UNAUTHORIZED'
|
|
6
|
+
| 'FORBIDDEN'
|
|
7
|
+
| 'NOT_FOUND'
|
|
8
|
+
| 'VALIDATION'
|
|
9
|
+
| 'SERVER'
|
|
10
|
+
| 'UNKNOWN';
|
|
11
|
+
|
|
12
|
+
export class ApiError extends Error {
|
|
13
|
+
readonly status?: number;
|
|
14
|
+
readonly code: ApiErrorCode;
|
|
15
|
+
readonly details?: unknown;
|
|
16
|
+
readonly requestId?: string;
|
|
17
|
+
|
|
18
|
+
constructor(options: {
|
|
19
|
+
message: string;
|
|
20
|
+
status?: number;
|
|
21
|
+
code?: ApiErrorCode;
|
|
22
|
+
details?: unknown;
|
|
23
|
+
requestId?: string;
|
|
24
|
+
cause?: unknown;
|
|
25
|
+
}) {
|
|
26
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined);
|
|
27
|
+
this.name = 'ApiError';
|
|
28
|
+
this.status = options.status;
|
|
29
|
+
this.code = options.code ?? 'UNKNOWN';
|
|
30
|
+
this.details = options.details;
|
|
31
|
+
this.requestId = options.requestId;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get isUnauthorized() {
|
|
35
|
+
return this.status === 401 || this.code === 'UNAUTHORIZED';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get isRetryable() {
|
|
39
|
+
return this.code === 'NETWORK' || this.code === 'TIMEOUT' || this.code === 'SERVER';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mapStatusToCode(status?: number): ApiErrorCode {
|
|
44
|
+
if (!status) return 'NETWORK';
|
|
45
|
+
if (status === 401) return 'UNAUTHORIZED';
|
|
46
|
+
if (status === 403) return 'FORBIDDEN';
|
|
47
|
+
if (status === 404) return 'NOT_FOUND';
|
|
48
|
+
if (status === 422 || status === 400) return 'VALIDATION';
|
|
49
|
+
if (status >= 500) return 'SERVER';
|
|
50
|
+
return 'UNKNOWN';
|
|
51
|
+
}
|
|
52
|
+
<% } else { -%>
|
|
53
|
+
export class ApiError extends Error {
|
|
54
|
+
constructor(options) {
|
|
55
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined);
|
|
56
|
+
this.name = 'ApiError';
|
|
57
|
+
this.status = options.status;
|
|
58
|
+
this.code = options.code ?? 'UNKNOWN';
|
|
59
|
+
this.details = options.details;
|
|
60
|
+
this.requestId = options.requestId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get isUnauthorized() {
|
|
64
|
+
return this.status === 401 || this.code === 'UNAUTHORIZED';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get isRetryable() {
|
|
68
|
+
return this.code === 'NETWORK' || this.code === 'TIMEOUT' || this.code === 'SERVER';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mapStatusToCode(status) {
|
|
73
|
+
if (!status) return 'NETWORK';
|
|
74
|
+
if (status === 401) return 'UNAUTHORIZED';
|
|
75
|
+
if (status === 403) return 'FORBIDDEN';
|
|
76
|
+
if (status === 404) return 'NOT_FOUND';
|
|
77
|
+
if (status === 422 || status === 400) return 'VALIDATION';
|
|
78
|
+
if (status >= 500) return 'SERVER';
|
|
79
|
+
return 'UNKNOWN';
|
|
80
|
+
}
|
|
81
|
+
<% } -%>
|
|
@@ -28,7 +28,15 @@ export default mergeConfig(
|
|
|
28
28
|
globals: true,
|
|
29
29
|
environment: 'jsdom',
|
|
30
30
|
setupFiles: './tests/setup.<%= ext %>',
|
|
31
|
-
|
|
31
|
+
include: ['tests/**/*.{test,spec}.{<%= isTypeScript ? 'ts,tsx' : 'js,jsx' %>}'],
|
|
32
|
+
exclude: [
|
|
33
|
+
'**/node_modules/**',
|
|
34
|
+
'**/dist/**',
|
|
35
|
+
'**/build/**',
|
|
36
|
+
'**/tests/e2e/**',
|
|
37
|
+
'**/e2e/**',
|
|
38
|
+
'**/*.e2e.*',
|
|
39
|
+
],
|
|
32
40
|
},
|
|
33
41
|
}),
|
|
34
42
|
);
|
|
@@ -3,6 +3,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|
|
3
3
|
/**
|
|
4
4
|
* Run once before first e2e: `<%= packageManager === 'npm' ? 'npm run' : packageManager %> test:e2e:install`
|
|
5
5
|
*/
|
|
6
|
+
<% const e2ePort = buildTool === 'webpack' ? 3000 : 5173; -%>
|
|
6
7
|
export default defineConfig({
|
|
7
8
|
testDir: './tests/e2e',
|
|
8
9
|
fullyParallel: true,
|
|
@@ -10,7 +11,7 @@ export default defineConfig({
|
|
|
10
11
|
retries: process.env.CI ? 1 : 0,
|
|
11
12
|
reporter: process.env.CI ? 'github' : 'list',
|
|
12
13
|
use: {
|
|
13
|
-
baseURL: 'http://127.0.0.1
|
|
14
|
+
baseURL: 'http://127.0.0.1:<%= e2ePort %>',
|
|
14
15
|
trace: 'on-first-retry',
|
|
15
16
|
screenshot: 'only-on-failure',
|
|
16
17
|
},
|
|
@@ -19,13 +20,13 @@ export default defineConfig({
|
|
|
19
20
|
'<%= buildTool === 'vite'
|
|
20
21
|
? 'npx vite --host 127.0.0.1 --port 5173'
|
|
21
22
|
: buildTool === 'webpack'
|
|
22
|
-
? 'npx webpack serve --mode development --port
|
|
23
|
+
? 'npx webpack serve --mode development --port 3000'
|
|
23
24
|
: packageManager === 'npm'
|
|
24
25
|
? 'npm run dev'
|
|
25
26
|
: packageManager === 'yarn'
|
|
26
27
|
? 'yarn dev'
|
|
27
28
|
: 'pnpm dev' %>',
|
|
28
|
-
url: 'http://127.0.0.1
|
|
29
|
+
url: 'http://127.0.0.1:<%= e2ePort %>',
|
|
29
30
|
reuseExistingServer: !process.env.CI,
|
|
30
31
|
timeout: 120_000,
|
|
31
32
|
},
|
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
|
2
2
|
<% if (isTypeScript) { -%>
|
|
3
|
-
import type { Post } from '@/
|
|
3
|
+
import type { FeedbackPayload, Post } from '@/types/models';
|
|
4
4
|
<% } -%>
|
|
5
5
|
import { env } from '@/config/env';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* RTK Query owns HTTP for this stack — no separate Axios/Fetch client is generated.
|
|
9
|
+
* Use `prepareHeaders` for auth and `tagTypes` for cache invalidation.
|
|
10
|
+
*/
|
|
9
11
|
export const postsApi = createApi({
|
|
10
12
|
reducerPath: 'postsApi',
|
|
11
|
-
baseQuery: fetchBaseQuery({
|
|
13
|
+
baseQuery: fetchBaseQuery({
|
|
14
|
+
baseUrl: env.apiBaseUrl,
|
|
15
|
+
prepareHeaders: (headers) => {
|
|
16
|
+
const token =
|
|
17
|
+
typeof window !== 'undefined' ? window.localStorage.getItem('access_token') : null;
|
|
18
|
+
if (token) {
|
|
19
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
20
|
+
}
|
|
21
|
+
headers.set('Accept', 'application/json');
|
|
22
|
+
return headers;
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
tagTypes: ['Posts'],
|
|
12
26
|
endpoints: (builder) => ({
|
|
13
27
|
getPosts: builder.query<% if (isTypeScript) { %><Post[], void><% } %>({
|
|
14
28
|
query: () => '/posts?_limit=5',
|
|
29
|
+
providesTags: ['Posts'],
|
|
30
|
+
}),
|
|
31
|
+
createFeedback: builder.mutation<% if (isTypeScript) { %><{ id: number }, FeedbackPayload><% } %>({
|
|
32
|
+
query: (body) => ({
|
|
33
|
+
url: '/posts',
|
|
34
|
+
method: 'POST',
|
|
35
|
+
body,
|
|
36
|
+
}),
|
|
37
|
+
invalidatesTags: ['Posts'],
|
|
15
38
|
}),
|
|
16
39
|
}),
|
|
17
40
|
});
|
|
18
41
|
|
|
19
|
-
export const { useGetPostsQuery } = postsApi;
|
|
42
|
+
export const { useGetPostsQuery, useCreateFeedbackMutation } = postsApi;
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
<% } -%>
|
|
5
5
|
"testEnvironment": "jsdom",
|
|
6
6
|
"setupFilesAfterEnv": ["<rootDir>/tests/setup.<%= ext %>"],
|
|
7
|
+
"testMatch": ["**/tests/**/*.test.[jt]s?(x)"],
|
|
8
|
+
"testPathIgnorePatterns": [
|
|
9
|
+
"/node_modules/",
|
|
10
|
+
"/dist/",
|
|
11
|
+
"/build/",
|
|
12
|
+
"/tests/e2e/",
|
|
13
|
+
"/e2e/"
|
|
14
|
+
],
|
|
7
15
|
"moduleNameMapper": {
|
|
8
16
|
"^@/(.*)$": "<rootDir>/src/$1",
|
|
9
17
|
"\\\\.(css|scss)$": "<rootDir>/tests/styleMock.<%= ext %>"
|
|
@@ -4,6 +4,19 @@ import userEvent from '@testing-library/user-event';
|
|
|
4
4
|
import { FeedbackForm } from '@/features/feedback/FeedbackForm';
|
|
5
5
|
import { renderWithProviders } from './test-utils';
|
|
6
6
|
|
|
7
|
+
<% if (serverState === 'rtk-query') { -%>
|
|
8
|
+
const createFeedback = vi.fn(() => ({
|
|
9
|
+
unwrap: vi.fn(async () => ({ id: 101 })),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('@/store/postsApi', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<% if (isTypeScript) { %><typeof import('@/store/postsApi')><% } %>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
useCreateFeedbackMutation: () => [createFeedback, { isLoading: false }],
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
<% } else { -%>
|
|
7
20
|
vi.mock('@/services/api', async (importOriginal) => {
|
|
8
21
|
const actual = await importOriginal<% if (isTypeScript) { %><typeof import('@/services/api')><% } %>();
|
|
9
22
|
return {
|
|
@@ -11,6 +24,7 @@ vi.mock('@/services/api', async (importOriginal) => {
|
|
|
11
24
|
createFeedback: vi.fn(async () => ({ id: 101 })),
|
|
12
25
|
};
|
|
13
26
|
});
|
|
27
|
+
<% } -%>
|
|
14
28
|
|
|
15
29
|
describe('FeedbackForm', () => {
|
|
16
30
|
beforeEach(() => {
|
|
@@ -30,7 +44,9 @@ describe('FeedbackForm', () => {
|
|
|
30
44
|
|
|
31
45
|
it('submits valid feedback and shows success status', async () => {
|
|
32
46
|
const user = userEvent.setup();
|
|
47
|
+
<% if (serverState !== 'rtk-query') { -%>
|
|
33
48
|
const { createFeedback } = await import('@/services/api');
|
|
49
|
+
<% } -%>
|
|
34
50
|
renderWithProviders(<FeedbackForm />);
|
|
35
51
|
|
|
36
52
|
await user.type(screen.getByPlaceholderText('Ada Lovelace'), 'Ada Lovelace');
|
|
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
|
|
|
4
4
|
import { PostsPanel } from '@/features/posts/PostsPanel';
|
|
5
5
|
import { renderWithProviders } from './test-utils';
|
|
6
6
|
|
|
7
|
+
<% if (serverState !== 'rtk-query') { -%>
|
|
7
8
|
vi.mock('@/services/api', async (importOriginal) => {
|
|
8
9
|
const actual = await importOriginal<% if (isTypeScript) { %><typeof import('@/services/api')><% } %>();
|
|
9
10
|
return {
|
|
@@ -14,9 +15,6 @@ vi.mock('@/services/api', async (importOriginal) => {
|
|
|
14
15
|
]),
|
|
15
16
|
};
|
|
16
17
|
});
|
|
17
|
-
|
|
18
|
-
<% if (serverState === 'rtk-query') { -%>
|
|
19
|
-
// RTK Query uses its own endpoint; mock still covers the transport used by other modes.
|
|
20
18
|
<% } -%>
|
|
21
19
|
|
|
22
20
|
describe('PostsPanel', () => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import react from '@vitejs/plugin-react';
|
|
4
|
+
import { defineConfig } from 'vitest/config';
|
|
5
|
+
|
|
6
|
+
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
// Standalone Vitest config for non-Vite build tools (Webpack, Parcel, etc.).
|
|
9
|
+
// Vite projects configure Vitest inside vite.config instead.
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
plugins: [react()],
|
|
12
|
+
resolve: {
|
|
13
|
+
alias: {
|
|
14
|
+
'@': path.resolve(rootDir, 'src'),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
test: {
|
|
18
|
+
globals: true,
|
|
19
|
+
environment: 'jsdom',
|
|
20
|
+
setupFiles: './tests/setup.<%= ext %>',
|
|
21
|
+
include: ['tests/**/*.{test,spec}.{<%= isTypeScript ? 'ts,tsx' : 'js,jsx' %>}'],
|
|
22
|
+
exclude: [
|
|
23
|
+
'**/node_modules/**',
|
|
24
|
+
'**/dist/**',
|
|
25
|
+
'**/build/**',
|
|
26
|
+
'**/tests/e2e/**',
|
|
27
|
+
'**/e2e/**',
|
|
28
|
+
'**/*.e2e.*',
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -21,52 +21,71 @@ You selected a coherent default-friendly stack during scaffolding. The generator
|
|
|
21
21
|
|
|
22
22
|
```text
|
|
23
23
|
src/
|
|
24
|
-
main.<%= jsxExt %>
|
|
25
|
-
providers.<%= jsxExt %>
|
|
26
|
-
App.<%= jsxExt %>
|
|
24
|
+
main.<%= jsxExt %> # bootstrap + render
|
|
25
|
+
providers.<%= jsxExt %> # global providers (store, query, theme)
|
|
26
|
+
App.<%= jsxExt %> # route tree
|
|
27
27
|
assets/
|
|
28
|
-
components/
|
|
29
|
-
config/
|
|
30
|
-
features/
|
|
28
|
+
components/ # shared UI (layout/, ui/)
|
|
29
|
+
config/ # env + app config
|
|
30
|
+
features/ # domain modules (counter, posts, feedback)
|
|
31
31
|
hooks/
|
|
32
|
-
lib/
|
|
33
|
-
routes/
|
|
34
|
-
services/
|
|
35
|
-
store/
|
|
32
|
+
lib/ # validation, monitoring helpers
|
|
33
|
+
routes/ # page-level route components
|
|
34
|
+
services/ # HTTP client + domain API (when selected)
|
|
35
|
+
store/ # client state + RTK Query APIs (when selected)
|
|
36
36
|
styles/
|
|
37
|
-
types/
|
|
37
|
+
types/ # shared domain models
|
|
38
38
|
utils/
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
**Rules of thumb**
|
|
42
42
|
|
|
43
43
|
- Put new product work in `src/features/<domain>/`.
|
|
44
|
-
- Keep `components/`
|
|
44
|
+
- Keep `components/` presentational and reusable.
|
|
45
45
|
- Import with the `@/` alias.
|
|
46
46
|
- Prefer a feature's public surface over deep cross-feature imports.
|
|
47
|
+
- Keep providers thin — wiring belongs in `providers.<%= jsxExt %>`, business logic does not.
|
|
47
48
|
|
|
48
49
|
## Routes
|
|
49
50
|
|
|
50
|
-
| Path | Purpose |
|
|
51
|
-
|
|
52
|
-
| `/` | Home overview |
|
|
53
|
-
| `/counter` | Client state
|
|
54
|
-
| `/posts` | Server state
|
|
55
|
-
| `/feedback` | Forms + validation |
|
|
56
|
-
| `/about` | Orientation |
|
|
51
|
+
| Path | Purpose | Primary demo |
|
|
52
|
+
|------|---------|--------------|
|
|
53
|
+
| `/` | Home overview | Stack orientation |
|
|
54
|
+
| `/counter` | Client state | <%= stackLabels.stateManagement %> |
|
|
55
|
+
| `/posts` | Server state | <%= stackLabels.serverState %> |
|
|
56
|
+
| `/feedback` | Forms + validation | <%= stackLabels.forms %> |
|
|
57
|
+
| `/about` | Orientation | Architecture notes |
|
|
57
58
|
|
|
58
59
|
## Path aliases
|
|
59
60
|
|
|
60
|
-
`@/*` maps to `src/*` in TypeScript/JS config and in your bundler.
|
|
61
|
+
`@/*` maps to `src/*` in TypeScript/JS config and in your bundler. Prefer `@/features/...` over long relative paths.
|
|
61
62
|
|
|
62
63
|
## Runtime composition
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
```text
|
|
66
|
+
main → AppProviders → App (router) → layout → page → feature panels
|
|
67
|
+
```
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
Provider order matters: client store → server cache → UI theme.
|
|
67
70
|
|
|
68
71
|
## Boundaries
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
- **
|
|
72
|
-
-
|
|
73
|
+
<% if (serverState === 'rtk-query') { -%>
|
|
74
|
+
- **HTTP** for remote resources lives in RTK Query APIs (`src/store/*Api.<%= ext %>`), not in components.
|
|
75
|
+
- Use `fetchBaseQuery` + `prepareHeaders` for auth, base URL, and shared headers.
|
|
76
|
+
- Do **not** add a parallel Axios/Fetch client unless you have a clear non-RTK integration (uploads to a different host, third-party SDK, etc.).
|
|
77
|
+
<% } else { -%>
|
|
78
|
+
- **UI** should call domain helpers in `src/services/api.<%= ext %>`.
|
|
79
|
+
- **Transport** details (interceptors, tokens, timeouts) stay in `src/services/http/`.
|
|
80
|
+
- Components should not call `axios` / `fetch` directly.
|
|
81
|
+
<% } -%>
|
|
82
|
+
- **Schemas** live in `lib/validation` and are reused by forms and API payloads.
|
|
83
|
+
- **Env access** stays in `config/`, services/store, and `lib/monitoring`.
|
|
84
|
+
- **Client state** holds UI/ephemeral state; **server state** caches remote entities.
|
|
85
|
+
|
|
86
|
+
## Scaling tips
|
|
87
|
+
|
|
88
|
+
1. Split large features into `components/`, `hooks/`, and `api/` subfolders inside the feature.
|
|
89
|
+
2. Promote shared types into `src/types/` once two features need them.
|
|
90
|
+
3. Keep route files thin — they compose features, they should not own data logic.
|
|
91
|
+
4. Prefer explicit query keys / endpoint names over anonymous fetch calls.
|