pejay-ui 1.0.4 → 1.1.0
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/README.md +7 -0
- package/bin/cli.js +83 -20
- package/package.json +2 -1
- package/registry.json +72 -224
- package/templates/scaffolds/tanstack-query/api-base.ts +68 -0
- package/templates/scaffolds/tanstack-query/api-mutations.ts +15 -0
- package/templates/scaffolds/tanstack-query/api-queries.ts +274 -0
- package/templates/scaffolds/tanstack-query/client.ts +63 -0
- package/templates/scaffolds/tanstack-query/module/index.ts +12 -0
- package/templates/scaffolds/tanstack-query/module/keys.ts +17 -0
- package/templates/scaffolds/tanstack-query/module/mappers.ts +15 -0
- package/templates/scaffolds/tanstack-query/module/mutations.ts +55 -0
- package/templates/scaffolds/tanstack-query/module/queries.ts +133 -0
- package/templates/scaffolds/tanstack-query/module/services.ts +66 -0
package/README.md
CHANGED
|
@@ -129,3 +129,10 @@ Below is the list of components you can add. Each has a copyable command block w
|
|
|
129
129
|
```bash
|
|
130
130
|
npx pejay-ui add dropdown/multiselect-input
|
|
131
131
|
```
|
|
132
|
+
|
|
133
|
+
### Scaffolds & Templates
|
|
134
|
+
|
|
135
|
+
* **`tanstack-query-client`**: Bare-bone TanStack Query client and context provider setup, copied directly into your project's `src/tanstack-query/`.
|
|
136
|
+
```bash
|
|
137
|
+
npx pejay-ui add tanstack-query-client
|
|
138
|
+
```
|
package/bin/cli.js
CHANGED
|
@@ -21,6 +21,23 @@ const prompt = async (questions) => {
|
|
|
21
21
|
return inquirer.default.prompt(questions);
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// Helper to get all files recursively from a directory
|
|
25
|
+
const getFilesRecursively = async (dir) => {
|
|
26
|
+
let results = [];
|
|
27
|
+
const list = await fs.readdir(dir);
|
|
28
|
+
for (const file of list) {
|
|
29
|
+
const filePath = path.join(dir, file);
|
|
30
|
+
const stat = await fs.stat(filePath);
|
|
31
|
+
if (stat && stat.isDirectory()) {
|
|
32
|
+
const subFiles = await getFilesRecursively(filePath);
|
|
33
|
+
results = results.concat(subFiles);
|
|
34
|
+
} else {
|
|
35
|
+
results.push(filePath);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return results;
|
|
39
|
+
};
|
|
40
|
+
|
|
24
41
|
program
|
|
25
42
|
.name("pejay-ui")
|
|
26
43
|
.description("CLI to initialize, add, and remove React UI components")
|
|
@@ -178,7 +195,12 @@ program
|
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
// 3. Process & Copy Component Files
|
|
181
|
-
|
|
198
|
+
let targetDir;
|
|
199
|
+
if (componentData.category === "tanstack-query") {
|
|
200
|
+
targetDir = path.join(cwd, "src", "tanstack-query");
|
|
201
|
+
} else {
|
|
202
|
+
targetDir = path.join(cwd, config.baseDir, "components", componentData.category);
|
|
203
|
+
}
|
|
182
204
|
const outputExt = isTsProject ? "tsx" : "jsx";
|
|
183
205
|
|
|
184
206
|
// Determine list of files to copy
|
|
@@ -192,29 +214,70 @@ program
|
|
|
192
214
|
process.exit(1);
|
|
193
215
|
}
|
|
194
216
|
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
const isDir = (await fs.stat(templateSrc)).isDirectory();
|
|
218
|
+
if (isDir) {
|
|
219
|
+
const allFiles = await getFilesRecursively(templateSrc);
|
|
220
|
+
for (const file of allFiles) {
|
|
221
|
+
const relativePath = path.relative(templateSrc, file);
|
|
222
|
+
const filename = relativePath.replace(/\.(tsx|ts)$/, (match) => {
|
|
223
|
+
return match === ".tsx" ? `.${outputExt}` : `.${isTsProject ? "ts" : "js"}`;
|
|
224
|
+
});
|
|
225
|
+
const targetFile = path.join(targetDir, filename);
|
|
226
|
+
|
|
227
|
+
let componentCode = await fs.readFile(file, "utf-8");
|
|
228
|
+
|
|
229
|
+
const fileDir = path.dirname(targetFile);
|
|
230
|
+
const relativeToUtils = path.relative(fileDir, targetUtilsDir).replace(/\\/g, "/");
|
|
231
|
+
const cnImportPath = isTsProject
|
|
232
|
+
? `${relativeToUtils}/cn`
|
|
233
|
+
: `${relativeToUtils}/cn.js`;
|
|
234
|
+
componentCode = componentCode.replace(/@\/utils\/cn/g, cnImportPath);
|
|
201
235
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
236
|
+
if (!isTsProject) {
|
|
237
|
+
const transformed = babel.transformSync(componentCode, {
|
|
238
|
+
presets: ["@babel/preset-typescript"],
|
|
239
|
+
filename: path.basename(file),
|
|
240
|
+
});
|
|
241
|
+
componentCode = transformed?.code || componentCode;
|
|
242
|
+
}
|
|
205
243
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
244
|
+
await fs.ensureDir(path.dirname(targetFile));
|
|
245
|
+
await fs.writeFile(targetFile, componentCode, "utf-8");
|
|
246
|
+
|
|
247
|
+
const relativeTargetFile = path.relative(cwd, targetFile).replace(/\\/g, "/");
|
|
248
|
+
console.log(`✅ Created ${relativeTargetFile}`);
|
|
249
|
+
installedFiles.push(path.relative(path.join(cwd, config.baseDir), targetFile).replace(/\\/g, "/"));
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
const filename = path.basename(srcFilePath).replace(/\.(tsx|ts)$/, (match) => {
|
|
253
|
+
return match === ".tsx" ? `.${outputExt}` : `.${isTsProject ? "ts" : "js"}`;
|
|
210
254
|
});
|
|
211
|
-
|
|
212
|
-
|
|
255
|
+
const targetFile = path.join(targetDir, filename);
|
|
256
|
+
|
|
257
|
+
let componentCode = await fs.readFile(templateSrc, "utf-8");
|
|
258
|
+
|
|
259
|
+
const fileDir = path.dirname(targetFile);
|
|
260
|
+
const relativeToUtils = path.relative(fileDir, targetUtilsDir).replace(/\\/g, "/");
|
|
261
|
+
const cnImportPath = isTsProject
|
|
262
|
+
? `${relativeToUtils}/cn`
|
|
263
|
+
: `${relativeToUtils}/cn.js`;
|
|
264
|
+
componentCode = componentCode.replace(/@\/utils\/cn/g, cnImportPath);
|
|
265
|
+
|
|
266
|
+
if (!isTsProject) {
|
|
267
|
+
const transformed = babel.transformSync(componentCode, {
|
|
268
|
+
presets: ["@babel/preset-typescript"],
|
|
269
|
+
filename: path.basename(srcFilePath),
|
|
270
|
+
});
|
|
271
|
+
componentCode = transformed?.code || componentCode;
|
|
272
|
+
}
|
|
213
273
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
274
|
+
await fs.ensureDir(targetDir);
|
|
275
|
+
await fs.writeFile(targetFile, componentCode, "utf-8");
|
|
276
|
+
|
|
277
|
+
const relativeTargetFile = path.relative(cwd, targetFile).replace(/\\/g, "/");
|
|
278
|
+
console.log(`✅ Created ${relativeTargetFile}`);
|
|
279
|
+
installedFiles.push(path.relative(path.join(cwd, config.baseDir), targetFile).replace(/\\/g, "/"));
|
|
280
|
+
}
|
|
218
281
|
}
|
|
219
282
|
|
|
220
283
|
// 4. Update State tracking in config
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pejay-ui",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "react ui components",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@babel/core": "^7.24.0",
|
|
24
24
|
"@babel/preset-typescript": "^7.24.0",
|
|
25
|
+
"@tanstack/react-query": "^5.100.14",
|
|
25
26
|
"commander": "^12.0.0",
|
|
26
27
|
"fs-extra": "^11.0.0",
|
|
27
28
|
"inquirer": "^9.0.0"
|
package/registry.json
CHANGED
|
@@ -2,328 +2,174 @@
|
|
|
2
2
|
"button": {
|
|
3
3
|
"name": "Button",
|
|
4
4
|
"category": "button",
|
|
5
|
-
"files": [
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
],
|
|
9
|
-
"utils": [
|
|
10
|
-
"cn.ts"
|
|
11
|
-
],
|
|
12
|
-
"peerDependencies": [
|
|
13
|
-
"clsx",
|
|
14
|
-
"tailwind-merge"
|
|
15
|
-
]
|
|
5
|
+
"files": ["templates/button/Button.tsx", "templates/button/tooltip.tsx"],
|
|
6
|
+
"utils": ["cn.ts"],
|
|
7
|
+
"peerDependencies": ["clsx", "tailwind-merge"]
|
|
16
8
|
},
|
|
17
9
|
"form/input": {
|
|
18
10
|
"name": "Input",
|
|
19
11
|
"category": "form",
|
|
20
|
-
"files": [
|
|
21
|
-
|
|
22
|
-
]
|
|
23
|
-
"utils": [
|
|
24
|
-
"cn.ts"
|
|
25
|
-
],
|
|
26
|
-
"peerDependencies": [
|
|
27
|
-
"clsx",
|
|
28
|
-
"tailwind-merge",
|
|
29
|
-
"lucide-react"
|
|
30
|
-
]
|
|
12
|
+
"files": ["templates/form/input.tsx"],
|
|
13
|
+
"utils": ["cn.ts"],
|
|
14
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
31
15
|
},
|
|
32
16
|
"form/amount-input": {
|
|
33
17
|
"name": "AmountInput",
|
|
34
18
|
"category": "form",
|
|
35
|
-
"files": [
|
|
36
|
-
|
|
37
|
-
]
|
|
38
|
-
"utils": [
|
|
39
|
-
"cn.ts"
|
|
40
|
-
],
|
|
41
|
-
"peerDependencies": [
|
|
42
|
-
"clsx",
|
|
43
|
-
"tailwind-merge",
|
|
44
|
-
"lucide-react"
|
|
45
|
-
]
|
|
19
|
+
"files": ["templates/form/amount-input.tsx"],
|
|
20
|
+
"utils": ["cn.ts"],
|
|
21
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
46
22
|
},
|
|
47
23
|
"form/checkbox": {
|
|
48
24
|
"name": "Checkbox",
|
|
49
25
|
"category": "form",
|
|
50
|
-
"files": [
|
|
51
|
-
|
|
52
|
-
]
|
|
53
|
-
"utils": [
|
|
54
|
-
"cn.ts"
|
|
55
|
-
],
|
|
56
|
-
"peerDependencies": [
|
|
57
|
-
"clsx",
|
|
58
|
-
"tailwind-merge",
|
|
59
|
-
"lucide-react"
|
|
60
|
-
]
|
|
26
|
+
"files": ["templates/form/checkbox.tsx"],
|
|
27
|
+
"utils": ["cn.ts"],
|
|
28
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
61
29
|
},
|
|
62
30
|
"form/checkbox-group": {
|
|
63
31
|
"name": "CheckboxGroup",
|
|
64
32
|
"category": "form",
|
|
65
|
-
"files": [
|
|
66
|
-
|
|
67
|
-
],
|
|
68
|
-
"
|
|
69
|
-
"cn.ts"
|
|
70
|
-
],
|
|
71
|
-
"peerDependencies": [
|
|
72
|
-
"clsx",
|
|
73
|
-
"tailwind-merge"
|
|
74
|
-
],
|
|
75
|
-
"dependencies": [
|
|
76
|
-
"form/checkbox"
|
|
77
|
-
]
|
|
33
|
+
"files": ["templates/form/checkbox-group.tsx"],
|
|
34
|
+
"utils": ["cn.ts"],
|
|
35
|
+
"peerDependencies": ["clsx", "tailwind-merge"],
|
|
36
|
+
"dependencies": ["form/checkbox"]
|
|
78
37
|
},
|
|
79
38
|
"form/date-picker": {
|
|
80
39
|
"name": "DatePicker",
|
|
81
40
|
"category": "form",
|
|
82
|
-
"files": [
|
|
83
|
-
|
|
84
|
-
],
|
|
85
|
-
"utils": [
|
|
86
|
-
"cn.ts"
|
|
87
|
-
],
|
|
41
|
+
"files": ["templates/form/date-picker.tsx"],
|
|
42
|
+
"utils": ["cn.ts"],
|
|
88
43
|
"peerDependencies": [
|
|
89
44
|
"clsx",
|
|
90
45
|
"tailwind-merge",
|
|
91
46
|
"lucide-react",
|
|
92
47
|
"@floating-ui/react"
|
|
93
48
|
],
|
|
94
|
-
"dependencies": [
|
|
95
|
-
"select-dropdown/select-input"
|
|
96
|
-
]
|
|
49
|
+
"dependencies": ["select-dropdown/select-input"]
|
|
97
50
|
},
|
|
98
51
|
"form/date-range-picker": {
|
|
99
52
|
"name": "DateRangePicker",
|
|
100
53
|
"category": "form",
|
|
101
|
-
"files": [
|
|
102
|
-
|
|
103
|
-
],
|
|
104
|
-
"utils": [
|
|
105
|
-
"cn.ts"
|
|
106
|
-
],
|
|
54
|
+
"files": ["templates/form/date-range-picker.tsx"],
|
|
55
|
+
"utils": ["cn.ts"],
|
|
107
56
|
"peerDependencies": [
|
|
108
57
|
"clsx",
|
|
109
58
|
"tailwind-merge",
|
|
110
59
|
"lucide-react",
|
|
111
60
|
"@floating-ui/react"
|
|
112
61
|
],
|
|
113
|
-
"dependencies": [
|
|
114
|
-
"select-dropdown/select-input"
|
|
115
|
-
]
|
|
62
|
+
"dependencies": ["select-dropdown/select-input"]
|
|
116
63
|
},
|
|
117
64
|
"form/email-input": {
|
|
118
65
|
"name": "EmailInput",
|
|
119
66
|
"category": "form",
|
|
120
|
-
"files": [
|
|
121
|
-
|
|
122
|
-
]
|
|
123
|
-
"utils": [
|
|
124
|
-
"cn.ts"
|
|
125
|
-
],
|
|
126
|
-
"peerDependencies": [
|
|
127
|
-
"clsx",
|
|
128
|
-
"tailwind-merge",
|
|
129
|
-
"lucide-react"
|
|
130
|
-
]
|
|
67
|
+
"files": ["templates/form/email-input.tsx"],
|
|
68
|
+
"utils": ["cn.ts"],
|
|
69
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
131
70
|
},
|
|
132
71
|
"form/file-input": {
|
|
133
72
|
"name": "FileInput",
|
|
134
73
|
"category": "form",
|
|
135
|
-
"files": [
|
|
136
|
-
|
|
137
|
-
]
|
|
138
|
-
"utils": [
|
|
139
|
-
"cn.ts"
|
|
140
|
-
],
|
|
141
|
-
"peerDependencies": [
|
|
142
|
-
"clsx",
|
|
143
|
-
"tailwind-merge",
|
|
144
|
-
"lucide-react"
|
|
145
|
-
]
|
|
74
|
+
"files": ["templates/form/file-input.tsx"],
|
|
75
|
+
"utils": ["cn.ts"],
|
|
76
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
146
77
|
},
|
|
147
78
|
"form/number-input": {
|
|
148
79
|
"name": "NumberInput",
|
|
149
80
|
"category": "form",
|
|
150
|
-
"files": [
|
|
151
|
-
|
|
152
|
-
]
|
|
153
|
-
"utils": [
|
|
154
|
-
"cn.ts"
|
|
155
|
-
],
|
|
156
|
-
"peerDependencies": [
|
|
157
|
-
"clsx",
|
|
158
|
-
"tailwind-merge",
|
|
159
|
-
"lucide-react"
|
|
160
|
-
]
|
|
81
|
+
"files": ["templates/form/number-input.tsx"],
|
|
82
|
+
"utils": ["cn.ts"],
|
|
83
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
161
84
|
},
|
|
162
85
|
"form/password-input": {
|
|
163
86
|
"name": "PasswordInput",
|
|
164
87
|
"category": "form",
|
|
165
|
-
"files": [
|
|
166
|
-
|
|
167
|
-
]
|
|
168
|
-
"utils": [
|
|
169
|
-
"cn.ts"
|
|
170
|
-
],
|
|
171
|
-
"peerDependencies": [
|
|
172
|
-
"clsx",
|
|
173
|
-
"tailwind-merge",
|
|
174
|
-
"lucide-react"
|
|
175
|
-
]
|
|
88
|
+
"files": ["templates/form/password-input.tsx"],
|
|
89
|
+
"utils": ["cn.ts"],
|
|
90
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
176
91
|
},
|
|
177
92
|
"form/phone-input": {
|
|
178
93
|
"name": "PhoneInput",
|
|
179
94
|
"category": "form",
|
|
180
|
-
"files": [
|
|
181
|
-
|
|
182
|
-
]
|
|
183
|
-
"utils": [
|
|
184
|
-
"cn.ts"
|
|
185
|
-
],
|
|
186
|
-
"peerDependencies": [
|
|
187
|
-
"clsx",
|
|
188
|
-
"tailwind-merge",
|
|
189
|
-
"lucide-react"
|
|
190
|
-
]
|
|
95
|
+
"files": ["templates/form/phone-input.tsx"],
|
|
96
|
+
"utils": ["cn.ts"],
|
|
97
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
191
98
|
},
|
|
192
99
|
"form/radio": {
|
|
193
100
|
"name": "Radio",
|
|
194
101
|
"category": "form",
|
|
195
|
-
"files": [
|
|
196
|
-
|
|
197
|
-
]
|
|
198
|
-
"utils": [
|
|
199
|
-
"cn.ts"
|
|
200
|
-
],
|
|
201
|
-
"peerDependencies": [
|
|
202
|
-
"clsx",
|
|
203
|
-
"tailwind-merge"
|
|
204
|
-
]
|
|
102
|
+
"files": ["templates/form/radio.tsx"],
|
|
103
|
+
"utils": ["cn.ts"],
|
|
104
|
+
"peerDependencies": ["clsx", "tailwind-merge"]
|
|
205
105
|
},
|
|
206
106
|
"form/radio-group": {
|
|
207
107
|
"name": "RadioGroup",
|
|
208
108
|
"category": "form",
|
|
209
|
-
"files": [
|
|
210
|
-
|
|
211
|
-
],
|
|
212
|
-
"
|
|
213
|
-
"cn.ts"
|
|
214
|
-
],
|
|
215
|
-
"peerDependencies": [
|
|
216
|
-
"clsx",
|
|
217
|
-
"tailwind-merge"
|
|
218
|
-
],
|
|
219
|
-
"dependencies": [
|
|
220
|
-
"form/radio"
|
|
221
|
-
]
|
|
109
|
+
"files": ["templates/form/radio-group.tsx"],
|
|
110
|
+
"utils": ["cn.ts"],
|
|
111
|
+
"peerDependencies": ["clsx", "tailwind-merge"],
|
|
112
|
+
"dependencies": ["form/radio"]
|
|
222
113
|
},
|
|
223
114
|
"form/range-slider": {
|
|
224
115
|
"name": "RangeSlider",
|
|
225
116
|
"category": "form",
|
|
226
|
-
"files": [
|
|
227
|
-
|
|
228
|
-
]
|
|
229
|
-
"utils": [
|
|
230
|
-
"cn.ts"
|
|
231
|
-
],
|
|
232
|
-
"peerDependencies": [
|
|
233
|
-
"clsx",
|
|
234
|
-
"tailwind-merge"
|
|
235
|
-
]
|
|
117
|
+
"files": ["templates/form/range-slider.tsx"],
|
|
118
|
+
"utils": ["cn.ts"],
|
|
119
|
+
"peerDependencies": ["clsx", "tailwind-merge"]
|
|
236
120
|
},
|
|
237
121
|
"form/switch": {
|
|
238
122
|
"name": "Switch",
|
|
239
123
|
"category": "form",
|
|
240
|
-
"files": [
|
|
241
|
-
|
|
242
|
-
]
|
|
243
|
-
"utils": [
|
|
244
|
-
"cn.ts"
|
|
245
|
-
],
|
|
246
|
-
"peerDependencies": [
|
|
247
|
-
"clsx",
|
|
248
|
-
"tailwind-merge"
|
|
249
|
-
]
|
|
124
|
+
"files": ["templates/form/switch.tsx"],
|
|
125
|
+
"utils": ["cn.ts"],
|
|
126
|
+
"peerDependencies": ["clsx", "tailwind-merge"]
|
|
250
127
|
},
|
|
251
128
|
"form/textarea": {
|
|
252
129
|
"name": "Textarea",
|
|
253
130
|
"category": "form",
|
|
254
|
-
"files": [
|
|
255
|
-
|
|
256
|
-
]
|
|
257
|
-
"utils": [
|
|
258
|
-
"cn.ts"
|
|
259
|
-
],
|
|
260
|
-
"peerDependencies": [
|
|
261
|
-
"clsx",
|
|
262
|
-
"tailwind-merge"
|
|
263
|
-
]
|
|
131
|
+
"files": ["templates/form/textarea.tsx"],
|
|
132
|
+
"utils": ["cn.ts"],
|
|
133
|
+
"peerDependencies": ["clsx", "tailwind-merge"]
|
|
264
134
|
},
|
|
265
135
|
"form/time-picker": {
|
|
266
136
|
"name": "TimePicker",
|
|
267
137
|
"category": "form",
|
|
268
|
-
"files": [
|
|
269
|
-
|
|
270
|
-
],
|
|
271
|
-
"utils": [
|
|
272
|
-
"cn.ts"
|
|
273
|
-
],
|
|
138
|
+
"files": ["templates/form/time-picker.tsx"],
|
|
139
|
+
"utils": ["cn.ts"],
|
|
274
140
|
"peerDependencies": [
|
|
275
141
|
"clsx",
|
|
276
142
|
"tailwind-merge",
|
|
277
143
|
"lucide-react",
|
|
278
144
|
"@floating-ui/react"
|
|
279
145
|
],
|
|
280
|
-
"dependencies": [
|
|
281
|
-
"select-dropdown/select-input"
|
|
282
|
-
]
|
|
146
|
+
"dependencies": ["select-dropdown/select-input"]
|
|
283
147
|
},
|
|
284
148
|
"form/time-range-picker": {
|
|
285
149
|
"name": "TimeRangePicker",
|
|
286
150
|
"category": "form",
|
|
287
|
-
"files": [
|
|
288
|
-
|
|
289
|
-
],
|
|
290
|
-
"utils": [
|
|
291
|
-
"cn.ts"
|
|
292
|
-
],
|
|
151
|
+
"files": ["templates/form/time-range-picker.tsx"],
|
|
152
|
+
"utils": ["cn.ts"],
|
|
293
153
|
"peerDependencies": [
|
|
294
154
|
"clsx",
|
|
295
155
|
"tailwind-merge",
|
|
296
156
|
"lucide-react",
|
|
297
157
|
"@floating-ui/react"
|
|
298
158
|
],
|
|
299
|
-
"dependencies": [
|
|
300
|
-
"select-dropdown/select-input"
|
|
301
|
-
]
|
|
159
|
+
"dependencies": ["select-dropdown/select-input"]
|
|
302
160
|
},
|
|
303
161
|
"form/url-input": {
|
|
304
162
|
"name": "UrlInput",
|
|
305
163
|
"category": "form",
|
|
306
|
-
"files": [
|
|
307
|
-
|
|
308
|
-
]
|
|
309
|
-
"utils": [
|
|
310
|
-
"cn.ts"
|
|
311
|
-
],
|
|
312
|
-
"peerDependencies": [
|
|
313
|
-
"clsx",
|
|
314
|
-
"tailwind-merge",
|
|
315
|
-
"lucide-react"
|
|
316
|
-
]
|
|
164
|
+
"files": ["templates/form/url-input.tsx"],
|
|
165
|
+
"utils": ["cn.ts"],
|
|
166
|
+
"peerDependencies": ["clsx", "tailwind-merge", "lucide-react"]
|
|
317
167
|
},
|
|
318
168
|
"dropdown/select-input": {
|
|
319
169
|
"name": "SelectInput",
|
|
320
170
|
"category": "select-dropdown",
|
|
321
|
-
"files": [
|
|
322
|
-
|
|
323
|
-
],
|
|
324
|
-
"utils": [
|
|
325
|
-
"cn.ts"
|
|
326
|
-
],
|
|
171
|
+
"files": ["templates/select-dropdown/select-input.tsx"],
|
|
172
|
+
"utils": ["cn.ts"],
|
|
327
173
|
"peerDependencies": [
|
|
328
174
|
"clsx",
|
|
329
175
|
"tailwind-merge",
|
|
@@ -334,17 +180,19 @@
|
|
|
334
180
|
"dropdown/multiselect-input": {
|
|
335
181
|
"name": "MultiselectInput",
|
|
336
182
|
"category": "select-dropdown",
|
|
337
|
-
"files": [
|
|
338
|
-
|
|
339
|
-
],
|
|
340
|
-
"utils": [
|
|
341
|
-
"cn.ts"
|
|
342
|
-
],
|
|
183
|
+
"files": ["templates/select-dropdown/multiselect-input.tsx"],
|
|
184
|
+
"utils": ["cn.ts"],
|
|
343
185
|
"peerDependencies": [
|
|
344
186
|
"clsx",
|
|
345
187
|
"tailwind-merge",
|
|
346
188
|
"lucide-react",
|
|
347
189
|
"@floating-ui/react"
|
|
348
190
|
]
|
|
191
|
+
},
|
|
192
|
+
"tanstack-query-client": {
|
|
193
|
+
"name": "TanstackQueryClient",
|
|
194
|
+
"category": "tanstack-query",
|
|
195
|
+
"files": ["templates/scaffolds/tanstack-query"],
|
|
196
|
+
"peerDependencies": ["@tanstack/react-query"]
|
|
349
197
|
}
|
|
350
198
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
#ANCHOR : TYPE:1 VITE PROJECT
|
|
3
|
+
const BASE_URL = import.meta.env.VITE_API_URL;
|
|
4
|
+
|
|
5
|
+
Environment variables must start with VITE_ to be exposed to client-side code in Vite.
|
|
6
|
+
|
|
7
|
+
---------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
#ANCHOR : TYPE:2 NEXT.JS / NON-VITE PROJECTS
|
|
10
|
+
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
11
|
+
|
|
12
|
+
Environment variables must start with NEXT_PUBLIC_ to be available in Next.js browser code.
|
|
13
|
+
|
|
14
|
+
---------------------------------------------------------
|
|
15
|
+
#ANCHOR
|
|
16
|
+
Vite doesn't automatically provide Node's process.env in browser code.
|
|
17
|
+
|
|
18
|
+
| Framework | Access Method | Public Prefix |
|
|
19
|
+
| --------------- | ------------------- | ---------------- |
|
|
20
|
+
| Next.js | `process.env.X` | `NEXT_PUBLIC_` |
|
|
21
|
+
| Vite | `import.meta.env.X` | `VITE_` |
|
|
22
|
+
| Node.js Backend | `process.env.X` | No prefix needed |
|
|
23
|
+
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const API_CONFIG = {
|
|
27
|
+
// NOTE: Change this BASE_URL to match your actual API endpoint
|
|
28
|
+
baseUrl: "http://localhost:5000/api",
|
|
29
|
+
timeout: 10000,
|
|
30
|
+
headers: {
|
|
31
|
+
"Accept": "application/json",
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
},
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
export const QUERY_CLIENT_CONFIG = {
|
|
37
|
+
defaultOptions: {
|
|
38
|
+
queries: {
|
|
39
|
+
// # NOTE: Global TanStack Query configurations
|
|
40
|
+
retry: 3, // Automatically retries failed requests 3 times on failure
|
|
41
|
+
refetchOnWindowFocus: false, // Refetches stale active queries when the browser tab gets focused
|
|
42
|
+
staleTime: 1000 * 60 * 5, // Data is considered fresh for 5 minutes (prevents duplicate requests)
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
# NOTE: HOW AND WHERE TO USE QUERY_CLIENT_CONFIG
|
|
49
|
+
|
|
50
|
+
This configuration is imported and passed when initializing the QueryClient at the root of your application (e.g., in App.tsx, main.tsx, or layout.tsx):
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
54
|
+
import { QUERY_CLIENT_CONFIG } from "./api-base";
|
|
55
|
+
|
|
56
|
+
const queryClient = new QueryClient(QUERY_CLIENT_CONFIG);
|
|
57
|
+
|
|
58
|
+
export default function Providers({ children }) {
|
|
59
|
+
return (
|
|
60
|
+
<QueryClientProvider client={queryClient}>
|
|
61
|
+
{children}
|
|
62
|
+
</QueryClientProvider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ModuleMutations } from "./module";
|
|
2
|
+
|
|
3
|
+
export const apiMutations = {
|
|
4
|
+
module: ModuleMutations,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export * from "./module";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
# NOTE : here you export all the mutations from the module folder so it will be easy to import in components
|
|
11
|
+
|
|
12
|
+
const queryClient = useQueryClient();
|
|
13
|
+
const mutation_name_example = useMutation(ModuleMutations.create_query_name_example(queryClient));
|
|
14
|
+
mutation_name_example.mutate({input_name: data})
|
|
15
|
+
*/
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { ModuleQueries } from "./module";
|
|
2
|
+
|
|
3
|
+
export const apiQueries = {
|
|
4
|
+
module: ModuleQueries,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export * from "./module";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
# NOTE : here you export all the queries from the module folder so it will be easy to import in components
|
|
11
|
+
|
|
12
|
+
const { data: name } = useQuery(ModuleQueries.fetch_query_name_example());
|
|
13
|
+
const { data: name } = useSuspenseQuery(ModuleQueries.fetch_query_name_example());
|
|
14
|
+
|
|
15
|
+
// Example with parameters & request cancellation:
|
|
16
|
+
// const { data } = useQuery(ModuleQueries.fetch_query_with_params_example({ search: 'query', filter: 'active' }));
|
|
17
|
+
|
|
18
|
+
---------------------------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
| Feature | `useQuery` | `useSuspenseQuery` |
|
|
21
|
+
| ------------------- | ---------------------------------------------- | --------------------------------------------------- |
|
|
22
|
+
| Loading state | Returns `isLoading`, `isPending`, `isFetching` | Does **not** return loading state |
|
|
23
|
+
| Data type | `data` can be `undefined` | `data` is guaranteed to exist when rendered |
|
|
24
|
+
| Error handling | Use `error` from hook | Error is caught by React Error Boundary |
|
|
25
|
+
| Loading UI | Handle manually (`if (isLoading)`) | Handled by React `<Suspense>` fallback |
|
|
26
|
+
| Component rendering | Renders immediately, then fetches | Suspends rendering until data is ready |
|
|
27
|
+
| Setup complexity | Simpler | Requires `<Suspense>` and usually an Error Boundary |
|
|
28
|
+
| Best for | Most applications | Apps fully using React Suspense |
|
|
29
|
+
|
|
30
|
+
----------------------------------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# NOTE : Example for useQuery
|
|
33
|
+
const ModuleComponent = () => {
|
|
34
|
+
|
|
35
|
+
const { data, isLoading, error } = useQuery(
|
|
36
|
+
ModuleQueries.fetch_query_name_example()
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (isLoading) return <FallBackComponent />;
|
|
40
|
+
if (error) return <ErrorComponent />;
|
|
41
|
+
|
|
42
|
+
return <ModuleComponent data={data} />;
|
|
43
|
+
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
-----------------------------------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
# NOTE : Example for useSuspenseQuery
|
|
49
|
+
|
|
50
|
+
const ModuleComponent = () => {
|
|
51
|
+
|
|
52
|
+
const { data } = useSuspenseQuery(
|
|
53
|
+
ModuleQueries.fetch_query_name_example()
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return <ModuleComponent data={data} />;
|
|
57
|
+
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# NOTE : Wrapping
|
|
61
|
+
<Suspense fallback={<FallBackComponent />}>
|
|
62
|
+
<ModuleComponent />
|
|
63
|
+
</Suspense>
|
|
64
|
+
|
|
65
|
+
---------------------------------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
Use useQuery when you want to manage loading and errors inside the component.
|
|
68
|
+
Use useSuspenseQuery when your app already uses React Suspense and you want cleaner components with guaranteed data.
|
|
69
|
+
|
|
70
|
+
---------------------------------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
# NOTE : Example for useInfiniteQuery (Infinite Scrolling)
|
|
73
|
+
|
|
74
|
+
1. In queries.ts, define the option:
|
|
75
|
+
export const ModuleQueries = {
|
|
76
|
+
fetch_infinite_query_example: () =>
|
|
77
|
+
infiniteQueryOptions({
|
|
78
|
+
queryKey: [...ModuleKeys.module(), "infinite"] as const,
|
|
79
|
+
queryFn: async ({ pageParam = 1 }) => {
|
|
80
|
+
const raw = await ModuleService.get_infinite_query_example(pageParam as number);
|
|
81
|
+
return raw as { data: any[]; meta: { current_page: number; last_page: number } };
|
|
82
|
+
},
|
|
83
|
+
getNextPageParam: (raw) => {
|
|
84
|
+
// E.g., if page-based (extracting from 'meta' object):
|
|
85
|
+
const { current_page, last_page } = raw?.meta || {};
|
|
86
|
+
return current_page < last_page ? current_page + 1 : undefined;
|
|
87
|
+
},
|
|
88
|
+
initialPageParam: 1,
|
|
89
|
+
select: (raw) => ({
|
|
90
|
+
pages: raw.pages.map((pageData: any) => ({
|
|
91
|
+
...pageData,
|
|
92
|
+
data: ModuleMappers.fetch_infinite_query_example(pageData.data),
|
|
93
|
+
})),
|
|
94
|
+
pageParams: raw.pageParams,
|
|
95
|
+
}),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
2. In your Component:
|
|
100
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
101
|
+
import { useEffect } from "react";
|
|
102
|
+
import { useInView } from "react-intersection-observer"; // optional, or use standard scroll listener
|
|
103
|
+
import { ModuleQueries } from "./queries";
|
|
104
|
+
|
|
105
|
+
const InfiniteListComponent = () => {
|
|
106
|
+
const {
|
|
107
|
+
data,
|
|
108
|
+
fetchNextPage,
|
|
109
|
+
hasNextPage,
|
|
110
|
+
isFetchingNextPage,
|
|
111
|
+
isLoading,
|
|
112
|
+
error
|
|
113
|
+
} = useInfiniteQuery(ModuleQueries.fetch_infinite_query_example());
|
|
114
|
+
|
|
115
|
+
// Optional hook to detect if target element is visible in the viewport
|
|
116
|
+
const { ref, inView } = useInView();
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (inView && hasNextPage && !isFetchingNextPage) {
|
|
120
|
+
fetchNextPage();
|
|
121
|
+
}
|
|
122
|
+
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
123
|
+
|
|
124
|
+
if (isLoading) return <Loading />;
|
|
125
|
+
if (error) return <Error />;
|
|
126
|
+
|
|
127
|
+
// Flatten the paginated data pages into a single flat array
|
|
128
|
+
const items = data ? data.pages.flatMap((page) => page.data) : [];
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div>
|
|
132
|
+
<ul>
|
|
133
|
+
{items.map((item) => (
|
|
134
|
+
<li key={item.id}>{item.name}</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
|
|
138
|
+
// This invisible boundary element triggers loading the next page when scrolled into view
|
|
139
|
+
<div ref={ref} style={{ height: "10px" }}>
|
|
140
|
+
{isFetchingNextPage ? "Loading more..." : ""}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
---------------------------------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
# HOW INFINITE SCROLLING WORKS:
|
|
149
|
+
1. **Initial Load:** `useInfiniteQuery` calls `queryFn` using `initialPageParam` (usually page `1`). The response is saved in `data.pages[0]`.
|
|
150
|
+
2. **Next Page Calculation:** `getNextPageParam` is called with the last fetched page data. It checks if there is more data (e.g., if `current_page < last_page`). If returned, `hasNextPage` becomes `true`.
|
|
151
|
+
3. **Scroll Trigger:** When the user scrolls down, an observer (like `react-intersection-observer` on the bottom `div` element) fires an `inView` event.
|
|
152
|
+
4. **Fetch Call:** If `inView`, `hasNextPage`, and not currently loading, we call `fetchNextPage()`.
|
|
153
|
+
5. **Caching & Appending:** TanStack Query triggers `queryFn` passing the next page number (e.g., `2`) as the new `pageParam`. The new data is fetched and appended as a new array element inside `data.pages` (e.g., `data.pages[1]`). The UI re-renders with the flattened items list.
|
|
154
|
+
|
|
155
|
+
---------------------------------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# NOTE : Example for Query Parameters (Filters & Cancellation)
|
|
158
|
+
|
|
159
|
+
This example shows a component using query parameters (including an array of brands) with automatic request cancellation:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { useState } from "react";
|
|
163
|
+
import { useQuery } from "@tanstack/react-query";
|
|
164
|
+
import { ModuleQueries } from "./queries";
|
|
165
|
+
import { useDebounce } from "@/hooks/use-debounce"; // custom debounce hook
|
|
166
|
+
|
|
167
|
+
const FilteredProductList = () => {
|
|
168
|
+
const [search, setSearch] = useState("");
|
|
169
|
+
const [selectedBrands, setSelectedBrands] = useState<string[]>([]); // e.g., ["samsung", "apple"]
|
|
170
|
+
const [sortBy, setSortBy] = useState("price_asc"); // Single key-value parameter example
|
|
171
|
+
|
|
172
|
+
// 1. Debounce fast inputs (like keystrokes) to prevent hammering the server
|
|
173
|
+
const debouncedSearch = useDebounce(search, 300);
|
|
174
|
+
|
|
175
|
+
// 2. Combine your states. Any change to these properties will update the queryKey
|
|
176
|
+
const filters = {
|
|
177
|
+
search: debouncedSearch,
|
|
178
|
+
brands: selectedBrands, // Array will be serialized as "brands=samsung&brands=apple" by the service
|
|
179
|
+
sort: sortBy, // Single key-value parameter (e.g., "price_asc")
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// 3. Pass the filter object directly to the query option builder
|
|
183
|
+
const { data, isLoading, isFetching, error } = useQuery(
|
|
184
|
+
ModuleQueries.fetch_query_with_params_example(filters)
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const toggleBrand = (brand: string) => {
|
|
188
|
+
setSelectedBrands(prev =>
|
|
189
|
+
prev.includes(brand) ? prev.filter(b => b !== brand) : [...prev, brand]
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div>
|
|
195
|
+
<input
|
|
196
|
+
type="text"
|
|
197
|
+
value={search}
|
|
198
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
199
|
+
placeholder="Search mobiles..."
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
|
203
|
+
<option value="price_asc">Price: Low to High</option>
|
|
204
|
+
<option value="price_desc">Price: High to Low</option>
|
|
205
|
+
</select>
|
|
206
|
+
|
|
207
|
+
<div>
|
|
208
|
+
<label>
|
|
209
|
+
<input
|
|
210
|
+
type="checkbox"
|
|
211
|
+
checked={selectedBrands.includes("samsung")}
|
|
212
|
+
onChange={() => toggleBrand("samsung")}
|
|
213
|
+
/> Samsung
|
|
214
|
+
</label>
|
|
215
|
+
<label>
|
|
216
|
+
<input
|
|
217
|
+
type="checkbox"
|
|
218
|
+
checked={selectedBrands.includes("apple")}
|
|
219
|
+
onChange={() => toggleBrand("apple")}
|
|
220
|
+
/> Apple
|
|
221
|
+
</label>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{isFetching && <span>Updating results (Old requests cancelled automatically)...</span>}
|
|
225
|
+
|
|
226
|
+
{isLoading ? (
|
|
227
|
+
<p>Loading...</p>
|
|
228
|
+
) : error ? (
|
|
229
|
+
<p>Error loading items</p>
|
|
230
|
+
) : (
|
|
231
|
+
<ul>
|
|
232
|
+
{data?.map((item: any) => (
|
|
233
|
+
<li key={item.id}>{item.name}</li>
|
|
234
|
+
))}
|
|
235
|
+
</ul>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---------------------------------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
# NOTE: WHY WE DO NOT GET DUPLICATE KEY ERRORS IN FRONTEND STATE
|
|
245
|
+
Even though the final URL repeats the same key (e.g., `?brands=samsung&brands=apple`),
|
|
246
|
+
your frontend React state or router object only has one clean unique key mapping to an array:
|
|
247
|
+
`const filters = { brands: ["samsung", "apple"] };`
|
|
248
|
+
|
|
249
|
+
The service layer safely iterates over the array and appends the items individually to the query parameters (`queryParams.append`).
|
|
250
|
+
This keeps your frontend state easy to manage without causing duplicate key errors.
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
// 1. Get query string from URL (or SessionStorage if URL is empty)
|
|
259
|
+
const searchParams = new URLSearchParams(window.location.search);
|
|
260
|
+
// 2. Parse the single values
|
|
261
|
+
const searchVal = searchParams.get("search") || "";
|
|
262
|
+
const sortVal = searchParams.get("sort") || "price_asc";
|
|
263
|
+
// 3. Parse the repeated values as an array
|
|
264
|
+
const brandsVal = searchParams.getAll("brands"); // returns ["samsung", "apple"]
|
|
265
|
+
// 4. Populate your component state
|
|
266
|
+
setSearch(searchVal);
|
|
267
|
+
setSortBy(sortVal);
|
|
268
|
+
setSelectedBrands(brandsVal);
|
|
269
|
+
}, []);
|
|
270
|
+
|
|
271
|
+
*/
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { API_CONFIG } from "./api-base";
|
|
2
|
+
|
|
3
|
+
//NOTE: apiRequest: This is the core function that all other methods use.
|
|
4
|
+
export async function apiRequest<T>(
|
|
5
|
+
endpoint: string,
|
|
6
|
+
options: RequestInit = {},
|
|
7
|
+
) {
|
|
8
|
+
// # NOTE: Retrieve your authentication token dynamically (e.g., from localStorage or cookies)
|
|
9
|
+
// const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
|
10
|
+
const token = null;
|
|
11
|
+
|
|
12
|
+
const response = await fetch(`${API_CONFIG.baseUrl}${endpoint}`, {
|
|
13
|
+
...options,
|
|
14
|
+
headers: {
|
|
15
|
+
...API_CONFIG.headers,
|
|
16
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
17
|
+
...options.headers,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (!response.ok) throw new Error(`Error Code ${response.status}`);
|
|
22
|
+
return (await response.json()) as T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function withJsonBody(method: string, body: any, options?: RequestInit) {
|
|
26
|
+
return {
|
|
27
|
+
...options,
|
|
28
|
+
method,
|
|
29
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
30
|
+
/*
|
|
31
|
+
# NOTE : fetch expects JSON bodies as strings
|
|
32
|
+
line this
|
|
33
|
+
body: '{"name":"John"}'
|
|
34
|
+
and not
|
|
35
|
+
body: data
|
|
36
|
+
in case of undefined - does a Undefined Body Check
|
|
37
|
+
body: "undefined"
|
|
38
|
+
*/
|
|
39
|
+
} satisfies RequestInit;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
|
|
44
|
+
Contains all api type calls
|
|
45
|
+
Usage:
|
|
46
|
+
const data = await apiClient.get<User>("/users/1");
|
|
47
|
+
const data = await apiClient.post<User>("/users", { name: "John" });
|
|
48
|
+
const data = await apiClient.put<User>("/users/1", { name: "John" });
|
|
49
|
+
const data = await apiClient.patch<User>("/users/1", { name: "John" });
|
|
50
|
+
const data = await apiClient.delete<User>("/users/1");
|
|
51
|
+
|
|
52
|
+
*/
|
|
53
|
+
export const apiClient = {
|
|
54
|
+
get: apiRequest,
|
|
55
|
+
post: <T>(endpoint: string, body?: any, options?: RequestInit) =>
|
|
56
|
+
apiRequest<T>(endpoint, withJsonBody("POST", body, options)),
|
|
57
|
+
put: <T>(endpoint: string, body?: any, options?: RequestInit) =>
|
|
58
|
+
apiRequest<T>(endpoint, withJsonBody("PUT", body, options)),
|
|
59
|
+
patch: <T>(endpoint: string, body?: any, options?: RequestInit) =>
|
|
60
|
+
apiRequest<T>(endpoint, withJsonBody("PATCH", body, options)),
|
|
61
|
+
delete: <T>(endpoint: string, options?: RequestInit) =>
|
|
62
|
+
apiRequest<T>(endpoint, { ...options, method: "DELETE" }),
|
|
63
|
+
} as const;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { ModuleKeys } from "./keys";
|
|
2
|
+
export { ModuleMappers } from "./mappers";
|
|
3
|
+
export { ModuleService } from "./services";
|
|
4
|
+
export { ModuleQueries } from "./queries";
|
|
5
|
+
export { ModuleMutations } from "./mutations";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
# NOTE: here we can export types.ts as well if we defining types seperately in a file
|
|
9
|
+
export type * from "./types";
|
|
10
|
+
# NOTE : we can also export a mocks.ts file if we using mocks which contains dummy / fall back data
|
|
11
|
+
export { ModuleMocks } from "./mocks";
|
|
12
|
+
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const ModuleKeys = {
|
|
2
|
+
module: () => ["moduleName"] as const,
|
|
3
|
+
fetch_query_name_example: () => [...ModuleKeys.module(), "fetch"] as const,
|
|
4
|
+
create_query_name_example: () => [...ModuleKeys.module(), "create"] as const,
|
|
5
|
+
update_query_name_example: () => [...ModuleKeys.module(), "update"] as const,
|
|
6
|
+
delete_query_name_example: () => [...ModuleKeys.module(), "delete"] as const,
|
|
7
|
+
fetch_infinite_query_name_example: () => [...ModuleKeys.module(), "fetch-infinite"] as const,
|
|
8
|
+
fetch_query_with_params_example: (params: any) => [...ModuleKeys.module(), "fetch-with-params", params] as const,
|
|
9
|
+
fetch_query_by_id_example: (id: string) => [...ModuleKeys.module(), "fetch-by-id", id] as const,
|
|
10
|
+
fetch_query_combo_example: (id: string, params: any) => [...ModuleKeys.module(), "fetch-combo", id, params] as const,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
# NOTE: as const is used at end so that keys are immutable
|
|
15
|
+
# NOTE : ans insted of standard object keys usd as function so its easy and clean to use and maintain in larger scale
|
|
16
|
+
# NOTE : you can change *_name_example to your own query name
|
|
17
|
+
*/
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
# NOTE : you can change *_name_example to your own query name
|
|
3
|
+
# NOTE : you can change raw to your own data after manipulation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ModuleMappers = {
|
|
7
|
+
fetch_query_name_example(raw: any) {
|
|
8
|
+
const data = raw || "manipulate your data here and then return it";
|
|
9
|
+
return data;
|
|
10
|
+
},
|
|
11
|
+
fetch_infinite_query_example(raw: any) {
|
|
12
|
+
const data = raw || [];
|
|
13
|
+
return data;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mutationOptions, type QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { ModuleKeys } from "./keys";
|
|
3
|
+
import { ModuleService } from "./services";
|
|
4
|
+
|
|
5
|
+
export const ModuleMutations = {
|
|
6
|
+
create_query_name_example: (queryClient: QueryClient) =>
|
|
7
|
+
mutationOptions({
|
|
8
|
+
mutationFn: (newItem: any) =>
|
|
9
|
+
ModuleService.post_query_name_example(newItem),
|
|
10
|
+
onMutate: async (newItem: any) => {
|
|
11
|
+
/*
|
|
12
|
+
# OPTIMISTIC UPDATE TECHNIQUE:
|
|
13
|
+
1. Cancel outgoing refetches so they don't overwrite our optimistic update.
|
|
14
|
+
2. Snapshot the current cache value.
|
|
15
|
+
3. Optimistically insert the new item into the cache.
|
|
16
|
+
4. Return context containing the previous value for error rollbacks.
|
|
17
|
+
*/
|
|
18
|
+
await queryClient.cancelQueries({ queryKey: ModuleKeys.fetch_query_name_example() });
|
|
19
|
+
|
|
20
|
+
const previousItems = queryClient.getQueryData(ModuleKeys.fetch_query_name_example());
|
|
21
|
+
|
|
22
|
+
queryClient.setQueryData(ModuleKeys.fetch_query_name_example(), (old: any) => {
|
|
23
|
+
return old ? [...old, newItem] : [newItem];
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return { previousItems };
|
|
27
|
+
},
|
|
28
|
+
onSuccess: async () => {
|
|
29
|
+
/*
|
|
30
|
+
# NOTE: In case of mutations, invalidate query keys to refresh with fresh server data.
|
|
31
|
+
*/
|
|
32
|
+
await queryClient.invalidateQueries({
|
|
33
|
+
queryKey: ModuleKeys.fetch_query_name_example(),
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
onError: (error, newItem, context: any) => {
|
|
37
|
+
/*
|
|
38
|
+
# ROLLBACK ON FAILURE:
|
|
39
|
+
If the mutation fails, rollback the cache to our snapshotted state.
|
|
40
|
+
*/
|
|
41
|
+
if (context?.previousItems) {
|
|
42
|
+
queryClient.setQueryData(
|
|
43
|
+
ModuleKeys.fetch_query_name_example(),
|
|
44
|
+
context.previousItems
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
onSettled: async () => {
|
|
49
|
+
// Always refetch after error or success to keep server in sync
|
|
50
|
+
await queryClient.invalidateQueries({
|
|
51
|
+
queryKey: ModuleKeys.fetch_query_name_example(),
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { queryOptions, infiniteQueryOptions, keepPreviousData } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
import { ModuleKeys } from "./keys";
|
|
4
|
+
import { ModuleMappers } from "./mappers";
|
|
5
|
+
import { ModuleService } from "./services";
|
|
6
|
+
|
|
7
|
+
export const ModuleQueries = {
|
|
8
|
+
fetch_query_name_example: () =>
|
|
9
|
+
queryOptions({
|
|
10
|
+
queryKey: ModuleKeys.fetch_query_name_example(),
|
|
11
|
+
queryFn: () => {
|
|
12
|
+
/*
|
|
13
|
+
# NOTE: ModuleService.get_query_name_example() -> hits the api of query fetch or GET
|
|
14
|
+
Return the Promise directly to TanStack Query so it can natively handle retries and error states.
|
|
15
|
+
*/
|
|
16
|
+
return ModuleService.get_query_name_example();
|
|
17
|
+
},
|
|
18
|
+
select: (raw) => {
|
|
19
|
+
/*
|
|
20
|
+
# NOTE: ModuleMappers.fetch_query_name_example() -> manipulates the data from api into desird format before returning to ui or page
|
|
21
|
+
Using the 'select' property memoizes this transformation (only runs when cached data changes).
|
|
22
|
+
*/
|
|
23
|
+
return ModuleMappers.fetch_query_name_example(raw as any);
|
|
24
|
+
},
|
|
25
|
+
/*
|
|
26
|
+
# NOTE: keeps the last successfully fetched data visible on screen
|
|
27
|
+
while fetching new data or if a subsequent fetch fails (preventing layout flickers/empty states).
|
|
28
|
+
*/
|
|
29
|
+
placeholderData: keepPreviousData,
|
|
30
|
+
}),
|
|
31
|
+
|
|
32
|
+
fetch_infinite_query_example: () =>
|
|
33
|
+
infiniteQueryOptions({
|
|
34
|
+
queryKey: ModuleKeys.fetch_infinite_query_name_example(),
|
|
35
|
+
queryFn: ({ pageParam = 1 }) => {
|
|
36
|
+
/*
|
|
37
|
+
# NOTE: ModuleService.get_infinite_query_example(pageParam) -> hits the api of query fetch or GET
|
|
38
|
+
Return the Promise directly to TanStack Query so it can natively handle retries and error states.
|
|
39
|
+
*/
|
|
40
|
+
return ModuleService.get_infinite_query_example(pageParam as number);
|
|
41
|
+
},
|
|
42
|
+
getNextPageParam: (raw: any) => {
|
|
43
|
+
// --- Option A: Cursor-based Pagination ---
|
|
44
|
+
// return raw.nextCursor ?? undefined;
|
|
45
|
+
|
|
46
|
+
// --- Option B: Page-number Metadata Pagination (last_page, current_page) ---
|
|
47
|
+
// Access metadata from the 'meta' object returned in your API response
|
|
48
|
+
const { current_page, last_page } = raw?.meta || {};
|
|
49
|
+
const hasMore = current_page < last_page;
|
|
50
|
+
return hasMore ? current_page + 1 : undefined;
|
|
51
|
+
},
|
|
52
|
+
initialPageParam: 1,
|
|
53
|
+
select: (raw) => {
|
|
54
|
+
/*
|
|
55
|
+
# NOTE: ModuleMappers.fetch_infinite_query_example() -> manipulates the data from api into desird format before returning to ui or page
|
|
56
|
+
Using the 'select' property memoizes this transformation (only runs when cached data changes).
|
|
57
|
+
*/
|
|
58
|
+
return {
|
|
59
|
+
pages: raw.pages.map((pageData: any) => ({
|
|
60
|
+
...pageData,
|
|
61
|
+
data: ModuleMappers.fetch_infinite_query_example(pageData.data),
|
|
62
|
+
})),
|
|
63
|
+
pageParams: raw.pageParams,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
placeholderData: keepPreviousData,
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
fetch_query_with_params_example: (params: Record<string, any>) =>
|
|
70
|
+
queryOptions({
|
|
71
|
+
queryKey: ModuleKeys.fetch_query_with_params_example(params),
|
|
72
|
+
queryFn: ({ signal }) => {
|
|
73
|
+
/*
|
|
74
|
+
# NOTE: Pass the native 'signal' from the TanStack queryFn context down to the service call.
|
|
75
|
+
This enables automatic cancellation if query parameters change or the component unmounts.
|
|
76
|
+
*/
|
|
77
|
+
return ModuleService.get_query_with_params_example(params, signal);
|
|
78
|
+
},
|
|
79
|
+
select: (raw) => {
|
|
80
|
+
// Reusing the same mapper example for consistency
|
|
81
|
+
return ModuleMappers.fetch_query_name_example(raw as any);
|
|
82
|
+
},
|
|
83
|
+
placeholderData: keepPreviousData,
|
|
84
|
+
}),
|
|
85
|
+
|
|
86
|
+
fetch_query_by_id_example: (id?: string | null) =>
|
|
87
|
+
queryOptions({
|
|
88
|
+
queryKey: ModuleKeys.fetch_query_by_id_example(id || ""),
|
|
89
|
+
queryFn: ({ signal }) => {
|
|
90
|
+
return ModuleService.get_query_by_id_example(id!, signal);
|
|
91
|
+
},
|
|
92
|
+
select: (raw) => {
|
|
93
|
+
return ModuleMappers.fetch_query_name_example(raw as any);
|
|
94
|
+
},
|
|
95
|
+
placeholderData: keepPreviousData,
|
|
96
|
+
/*
|
|
97
|
+
# NOTE: Conditional/Dependent Queries
|
|
98
|
+
Setting 'enabled: !!id' stops the query from executing automatically if the ID is missing (undefined, null, or empty).
|
|
99
|
+
*/
|
|
100
|
+
enabled: !!id,
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
fetch_query_combo_example: (id: string | null | undefined, params: Record<string, any>) =>
|
|
104
|
+
queryOptions({
|
|
105
|
+
queryKey: ModuleKeys.fetch_query_combo_example(id || "", params),
|
|
106
|
+
queryFn: ({ signal }) => {
|
|
107
|
+
return ModuleService.get_query_combo_example(id!, params, signal);
|
|
108
|
+
},
|
|
109
|
+
select: (raw) => {
|
|
110
|
+
return ModuleMappers.fetch_query_name_example(raw as any);
|
|
111
|
+
},
|
|
112
|
+
placeholderData: keepPreviousData,
|
|
113
|
+
enabled: !!id, // Automatically stops API call if ID is null/undefined
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/*
|
|
118
|
+
# NOTE: RAW DATA VS. MAPPED DATA & MEMOIZATION
|
|
119
|
+
|
|
120
|
+
1. **Where Raw Data Lives:**
|
|
121
|
+
- The raw response returned by the API (`queryFn`) is stored unmodified inside the **TanStack Query Cache**.
|
|
122
|
+
- This represents the exact payload from your backend database/server.
|
|
123
|
+
|
|
124
|
+
2. **Where Mapped Data Lives:**
|
|
125
|
+
- The mapped data is delivered directly to the **UI / React Component** consuming the hook.
|
|
126
|
+
- It is calculated on-the-fly by executing the `select` function on the cached raw data.
|
|
127
|
+
|
|
128
|
+
3. **How Memoization Works (Performance Optimization):**
|
|
129
|
+
- The `select` function is **automatically memoized** by TanStack Query.
|
|
130
|
+
- It will ONLY re-run when the cached raw data changes.
|
|
131
|
+
- If the component re-renders for other reasons (e.g., local UI states, parent re-renders, or window focus checks), TanStack Query skips the mapper execution completely and returns the already memoized mapped data instantly.
|
|
132
|
+
*/
|
|
133
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { apiClient } from "../client";
|
|
2
|
+
|
|
3
|
+
export const ModuleService = {
|
|
4
|
+
get_query_name_example: () => apiClient.get("/api_name/get"),
|
|
5
|
+
get_infinite_query_example: (page: number) =>
|
|
6
|
+
apiClient.get(`/api_name/list?page=${page}`),
|
|
7
|
+
get_query_with_params_example: (params: Record<string, any>, signal?: AbortSignal) => {
|
|
8
|
+
const queryParams = new URLSearchParams();
|
|
9
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
value.forEach((val) => {
|
|
12
|
+
if (val !== undefined && val !== null && val !== "") {
|
|
13
|
+
queryParams.append(key, String(val));
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
} else if (value !== undefined && value !== null && value !== "") {
|
|
17
|
+
queryParams.set(key, String(value));
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
return apiClient.get(`/api_name/get-with-params?${queryParams.toString()}`, { signal });
|
|
21
|
+
},
|
|
22
|
+
get_query_by_id_example: (id: string, signal?: AbortSignal) =>
|
|
23
|
+
apiClient.get(`/api_name/get-by-id/${id}`, { signal }),
|
|
24
|
+
get_query_combo_example: (id: string, params: Record<string, any>, signal?: AbortSignal) => {
|
|
25
|
+
const queryParams = new URLSearchParams();
|
|
26
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
value.forEach((val) => {
|
|
29
|
+
if (val !== undefined && val !== null && val !== "") {
|
|
30
|
+
queryParams.append(key, String(val));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
} else if (value !== undefined && value !== null && value !== "") {
|
|
34
|
+
queryParams.set(key, String(value));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return apiClient.get(`/api_name/get-combo/${id}?${queryParams.toString()}`, { signal });
|
|
38
|
+
},
|
|
39
|
+
post_query_name_example: (input: any) =>
|
|
40
|
+
apiClient.post("/api_name/post", input),
|
|
41
|
+
patch_query_name_example: (input: any) =>
|
|
42
|
+
apiClient.patch("/api_name/patch", input),
|
|
43
|
+
delete_query_name_example: (id: string) =>
|
|
44
|
+
apiClient.delete(`/api_name/${id}`),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
# NOTE: you can change *_name_example to your own query name
|
|
49
|
+
# NOTE: you can change /api_name to your own api name
|
|
50
|
+
# NOTE: you can change input to your own data type
|
|
51
|
+
# NOTE: you can change id to your own data type
|
|
52
|
+
|
|
53
|
+
-------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
# NOTE: WHY WE USE URLSearchParams & THE ROLE OF THE SERVICE LAYER
|
|
56
|
+
|
|
57
|
+
1. **Why we use `URLSearchParams`:**
|
|
58
|
+
- **Automatic URL-Encoding:** It automatically sanitizes special characters (like spaces, commas, or quotes) into browser-safe formats (e.g., space becomes `%20`), preventing broken URLs.
|
|
59
|
+
- **Dynamic Query String Building:** It generates the final string from a raw object dynamically (calling `.toString()` results in `key1=val1&key2=val2`).
|
|
60
|
+
|
|
61
|
+
2. **Role of this Service Layer (`get_query_with_params_example`):**
|
|
62
|
+
- **Decoupled Contracts:** It keeps components "dumb" about network specifics. The component only needs to pass a clean JavaScript object containing the filters.
|
|
63
|
+
- **Centralization:** If endpoints change or parameter serialization logic needs to adjust in the future, it is managed in this single file rather than modifying multiple UI files.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
|