@intrig/plugin-react 0.0.1
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/.swcrc +29 -0
- package/README.md +7 -0
- package/eslint.config.mjs +19 -0
- package/package.json +25 -0
- package/project.json +29 -0
- package/rollup.config.cjs +54 -0
- package/rollup.config.mjs +33 -0
- package/src/index.ts +2 -0
- package/src/lib/code-generator.ts +79 -0
- package/src/lib/get-endpoint-documentation.ts +35 -0
- package/src/lib/get-schema-documentation.ts +11 -0
- package/src/lib/internal-types.ts +15 -0
- package/src/lib/plugin-react.ts +22 -0
- package/src/lib/templates/context.template.ts +74 -0
- package/src/lib/templates/docs/__snapshots__/async-hook.spec.ts.snap +889 -0
- package/src/lib/templates/docs/__snapshots__/download-hook.spec.ts.snap +1445 -0
- package/src/lib/templates/docs/__snapshots__/react-hook.spec.ts.snap +1371 -0
- package/src/lib/templates/docs/__snapshots__/sse-hook.spec.ts.snap +2008 -0
- package/src/lib/templates/docs/async-hook.spec.ts +92 -0
- package/src/lib/templates/docs/async-hook.ts +226 -0
- package/src/lib/templates/docs/download-hook.spec.ts +182 -0
- package/src/lib/templates/docs/download-hook.ts +170 -0
- package/src/lib/templates/docs/react-hook.spec.ts +97 -0
- package/src/lib/templates/docs/react-hook.ts +323 -0
- package/src/lib/templates/docs/schema.ts +105 -0
- package/src/lib/templates/docs/sse-hook.spec.ts +207 -0
- package/src/lib/templates/docs/sse-hook.ts +221 -0
- package/src/lib/templates/extra.template.ts +198 -0
- package/src/lib/templates/index.template.ts +14 -0
- package/src/lib/templates/intrigMiddleware.template.ts +21 -0
- package/src/lib/templates/logger.template.ts +67 -0
- package/src/lib/templates/media-type-utils.template.ts +191 -0
- package/src/lib/templates/network-state.template.ts +702 -0
- package/src/lib/templates/packageJson.template.ts +63 -0
- package/src/lib/templates/provider/__tests__/provider-templates.spec.ts +209 -0
- package/src/lib/templates/provider/axios-config.template.ts +49 -0
- package/src/lib/templates/provider/hooks.template.ts +240 -0
- package/src/lib/templates/provider/interfaces.template.ts +72 -0
- package/src/lib/templates/provider/intrig-provider-stub.template.ts +73 -0
- package/src/lib/templates/provider/intrig-provider.template.ts +185 -0
- package/src/lib/templates/provider/main.template.ts +48 -0
- package/src/lib/templates/provider/reducer.template.ts +50 -0
- package/src/lib/templates/provider/status-trap.template.ts +80 -0
- package/src/lib/templates/provider.template.ts +698 -0
- package/src/lib/templates/source/controller/method/asyncFunctionHook.template.ts +196 -0
- package/src/lib/templates/source/controller/method/clientIndex.template.ts +38 -0
- package/src/lib/templates/source/controller/method/download.template.ts +256 -0
- package/src/lib/templates/source/controller/method/params.template.ts +31 -0
- package/src/lib/templates/source/controller/method/requestHook.template.ts +220 -0
- package/src/lib/templates/source/type/typeTemplate.ts +257 -0
- package/src/lib/templates/swcrc.template.ts +25 -0
- package/src/lib/templates/tsconfig.template.ts +37 -0
- package/src/lib/templates/type-utils.template.ts +28 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +20 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { reactAsyncFunctionHookDocs } from './async-hook';
|
|
2
|
+
|
|
3
|
+
// We use a plain object cast to any to avoid direct dependency on the types from "@intrig/plugin-sdk" in test.
|
|
4
|
+
|
|
5
|
+
describe('reactAsyncFunctionHookDocs', () => {
|
|
6
|
+
it('snapshot — simple REST descriptor (no body, no path params)', async () => {
|
|
7
|
+
const descriptor = {
|
|
8
|
+
id: '1',
|
|
9
|
+
name: 'validateUsername',
|
|
10
|
+
type: 'rest' as const,
|
|
11
|
+
source: 'demo_api',
|
|
12
|
+
path: 'users',
|
|
13
|
+
data: {
|
|
14
|
+
method: 'GET',
|
|
15
|
+
paths: ['/api/users/validate-username'],
|
|
16
|
+
operationId: 'validateUsername',
|
|
17
|
+
// no requestBody
|
|
18
|
+
// no variables
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const result = await reactAsyncFunctionHookDocs(descriptor as any);
|
|
23
|
+
|
|
24
|
+
expect(result.path).toBe('async-hook.md');
|
|
25
|
+
expect(result.content).toMatchSnapshot();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('snapshot — request body only (no path params)', async () => {
|
|
29
|
+
const descriptor = {
|
|
30
|
+
id: '2',
|
|
31
|
+
name: 'checkPasswordStrength',
|
|
32
|
+
type: 'rest' as const,
|
|
33
|
+
source: 'demo_api',
|
|
34
|
+
path: 'security',
|
|
35
|
+
data: {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
paths: ['/api/security/check-password'],
|
|
38
|
+
operationId: 'checkPasswordStrength',
|
|
39
|
+
requestBody: 'CheckPasswordStrengthRequest',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const result = await reactAsyncFunctionHookDocs(descriptor as any);
|
|
44
|
+
expect(result.path).toBe('async-hook.md');
|
|
45
|
+
expect(result.content).toMatchSnapshot();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('snapshot — path params only (no request body)', async () => {
|
|
49
|
+
const descriptor = {
|
|
50
|
+
id: '3',
|
|
51
|
+
name: 'verifyUserById',
|
|
52
|
+
type: 'rest' as const,
|
|
53
|
+
source: 'demo_api',
|
|
54
|
+
path: 'users/{id}',
|
|
55
|
+
data: {
|
|
56
|
+
method: 'GET',
|
|
57
|
+
paths: ['/api/users/{id}/verify'],
|
|
58
|
+
operationId: 'verifyUserById',
|
|
59
|
+
variables: [
|
|
60
|
+
{ name: 'id', in: 'path', ref: '#/components/schemas/Id' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const result = await reactAsyncFunctionHookDocs(descriptor as any);
|
|
66
|
+
expect(result.path).toBe('async-hook.md');
|
|
67
|
+
expect(result.content).toMatchSnapshot();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('snapshot — request body and path params', async () => {
|
|
71
|
+
const descriptor = {
|
|
72
|
+
id: '4',
|
|
73
|
+
name: 'recalculateUserScore',
|
|
74
|
+
type: 'rest' as const,
|
|
75
|
+
source: 'demo_api',
|
|
76
|
+
path: 'users/{id}',
|
|
77
|
+
data: {
|
|
78
|
+
method: 'PUT',
|
|
79
|
+
paths: ['/api/users/{id}/score'],
|
|
80
|
+
operationId: 'recalculateUserScore',
|
|
81
|
+
requestBody: 'RecalculateUserScoreRequest',
|
|
82
|
+
variables: [
|
|
83
|
+
{ name: 'id', in: 'PATH', ref: '#/components/schemas/Id' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const result = await reactAsyncFunctionHookDocs(descriptor as any);
|
|
89
|
+
expect(result.path).toBe('async-hook.md');
|
|
90
|
+
expect(result.content).toMatchSnapshot();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { camelCase, mdLiteral, pascalCase, ResourceDescriptor, RestData } from "@intrig/plugin-sdk"
|
|
2
|
+
|
|
3
|
+
export function reactAsyncFunctionHookDocs(descriptor: ResourceDescriptor<RestData>) {
|
|
4
|
+
const md = mdLiteral('async-hook.md')
|
|
5
|
+
|
|
6
|
+
// ===== Derived names (preserve these) =====
|
|
7
|
+
const hasPathParams = (descriptor.data.variables ?? []).some(
|
|
8
|
+
(v: any) => v.in?.toUpperCase() === 'PATH',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const actionName = camelCase(descriptor.name) // e.g. getUser
|
|
12
|
+
const abortName = `abort${pascalCase(descriptor.name)}` // e.g. abortGetUser
|
|
13
|
+
|
|
14
|
+
const requestBodyVar = descriptor.data.requestBody
|
|
15
|
+
? camelCase(descriptor.data.requestBody) // e.g. createUser
|
|
16
|
+
: undefined
|
|
17
|
+
const requestBodyType = descriptor.data.requestBody
|
|
18
|
+
? pascalCase(descriptor.data.requestBody) // e.g. CreateUser
|
|
19
|
+
: undefined
|
|
20
|
+
|
|
21
|
+
const paramsVar = hasPathParams ? `${actionName}Params` : undefined // e.g. getUserParams
|
|
22
|
+
const paramsType = hasPathParams ? `${pascalCase(descriptor.name)}Params` : undefined // e.g. GetUserParams
|
|
23
|
+
const responseTypeName = `${pascalCase(descriptor.name)}ResponseBody` // e.g. GetUserResponseBody
|
|
24
|
+
|
|
25
|
+
const callArgs = [requestBodyVar, paramsVar].filter(Boolean).join(', ')
|
|
26
|
+
|
|
27
|
+
return md`
|
|
28
|
+
# Intrig Async Hooks — Quick Guide
|
|
29
|
+
|
|
30
|
+
## Copy-paste starter (fast lane)
|
|
31
|
+
|
|
32
|
+
### 1) Hook import
|
|
33
|
+
${"```ts"}
|
|
34
|
+
import { use${pascalCase(descriptor.name)}Async } from '@intrig/react/${descriptor.path}/client';
|
|
35
|
+
${"```"}
|
|
36
|
+
|
|
37
|
+
### 2) Create an instance
|
|
38
|
+
${"```ts"}
|
|
39
|
+
const [${actionName}, ${abortName}] = use${pascalCase(descriptor.name)}Async();
|
|
40
|
+
${"```"}
|
|
41
|
+
|
|
42
|
+
### 3) Call it (awaitable)
|
|
43
|
+
${"```ts"}
|
|
44
|
+
// body?, params? — pass what your endpoint needs (order: body, params)
|
|
45
|
+
await ${actionName}(${callArgs});
|
|
46
|
+
${"```"}
|
|
47
|
+
|
|
48
|
+
Async hooks are for one-off, low-friction calls (e.g., validations, submissions). They return an **awaitable function** plus an **abort** function. No NetworkState.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## TL;DR (copy–paste)
|
|
53
|
+
${"```tsx"}
|
|
54
|
+
import { use${pascalCase(descriptor.name)}Async } from '@intrig/react/${descriptor.path}/client';
|
|
55
|
+
import { useCallback, useEffect } from 'react';
|
|
56
|
+
|
|
57
|
+
export default function Example() {
|
|
58
|
+
const [${actionName}, ${abortName}] = use${pascalCase(descriptor.name)}Async();
|
|
59
|
+
|
|
60
|
+
const run = useCallback(async () => {
|
|
61
|
+
try {
|
|
62
|
+
const result = await ${actionName}(${callArgs});
|
|
63
|
+
// do something with result
|
|
64
|
+
console.log(result);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// request failed or was aborted
|
|
67
|
+
console.error(e);
|
|
68
|
+
}
|
|
69
|
+
}, [${actionName}]);
|
|
70
|
+
|
|
71
|
+
// Optional: abort on unmount
|
|
72
|
+
useEffect(() => ${abortName}, [${abortName}]);
|
|
73
|
+
|
|
74
|
+
return <button onClick={run}>Call</button>;
|
|
75
|
+
}
|
|
76
|
+
${"```"}
|
|
77
|
+
|
|
78
|
+
${requestBodyType || paramsType ? `### Optional types (if generated by your build)
|
|
79
|
+
${"```ts"}
|
|
80
|
+
${requestBodyType ? `import type { ${requestBodyType} } from '@intrig/react/${descriptor.source}/components/schemas/${requestBodyType}';
|
|
81
|
+
` : ''}${paramsType ? `import type { ${paramsType} } from '@intrig/react/${descriptor.path}/${pascalCase(descriptor.name)}.params';
|
|
82
|
+
` : ''}import type { ${responseTypeName} } from '@intrig/react/${descriptor.path}/${pascalCase(descriptor.name)}.response';
|
|
83
|
+
${"```"}
|
|
84
|
+
` : ''}
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Hook API
|
|
89
|
+
${"```ts"}
|
|
90
|
+
// Prefer concrete types if your build emits them:
|
|
91
|
+
// import type { ${responseTypeName} } from '@intrig/react/${descriptor.path}/${pascalCase(descriptor.name)}.response';
|
|
92
|
+
// ${paramsType ? `import type { ${paramsType} } from '@intrig/react/${descriptor.path}/${pascalCase(descriptor.name)}.params';` : ''}
|
|
93
|
+
|
|
94
|
+
type ${pascalCase(descriptor.name)}Data = ${'unknown'}; // replace with ${responseTypeName} if generated
|
|
95
|
+
type ${pascalCase(descriptor.name)}Request = {
|
|
96
|
+
body?: ${requestBodyType ?? 'unknown'};
|
|
97
|
+
params?: ${paramsType ?? 'unknown'};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Signature (shape shown; return type depends on your endpoint)
|
|
101
|
+
declare function use${pascalCase(descriptor.name)}Async(): [
|
|
102
|
+
(body?: ${pascalCase(descriptor.name)}Request['body'], params?: ${pascalCase(descriptor.name)}Request['params']) => Promise<${pascalCase(descriptor.name)}Data>,
|
|
103
|
+
() => void // abort
|
|
104
|
+
];
|
|
105
|
+
${"```"}
|
|
106
|
+
|
|
107
|
+
### Why async hooks?
|
|
108
|
+
- **No state machine:** just \`await\` the result.
|
|
109
|
+
- **Great for validations & submits:** uniqueness checks, field-level checks, updates.
|
|
110
|
+
- **Abortable:** cancel in-flight work on demand.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Usage Patterns
|
|
115
|
+
|
|
116
|
+
### 1) Simple try/catch (recommended)
|
|
117
|
+
${"```tsx"}
|
|
118
|
+
const [${actionName}] = use${pascalCase(descriptor.name)}Async();
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const res = await ${actionName}(${callArgs});
|
|
122
|
+
// use res
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// network error or abort
|
|
125
|
+
}
|
|
126
|
+
${"```"}
|
|
127
|
+
|
|
128
|
+
<details><summary>Description</summary>
|
|
129
|
+
<p><strong>Use when</strong> you just need the value or an error. Ideal for validators, uniqueness checks, or quick lookups.</p>
|
|
130
|
+
</details>
|
|
131
|
+
|
|
132
|
+
### 2) Abort on unmount (safe cleanup)
|
|
133
|
+
${"```tsx"}
|
|
134
|
+
const [${actionName}, ${abortName}] = use${pascalCase(descriptor.name)}Async();
|
|
135
|
+
|
|
136
|
+
useEffect(() => ${abortName}, [${abortName}]);
|
|
137
|
+
${"```"}
|
|
138
|
+
|
|
139
|
+
<details><summary>Description</summary>
|
|
140
|
+
<p><strong>Use when</strong> the component may unmount while a request is in-flight (route changes, conditional UI).</p>
|
|
141
|
+
</details>
|
|
142
|
+
|
|
143
|
+
### 3) Debounced validation (e.g., on input change)
|
|
144
|
+
${"```tsx"}
|
|
145
|
+
const [${actionName}, ${abortName}] = use${pascalCase(descriptor.name)}Async();
|
|
146
|
+
|
|
147
|
+
const onChange = useMemo(() => {
|
|
148
|
+
let t: any;
|
|
149
|
+
return (${requestBodyVar ? `${requestBodyVar}: ${requestBodyType ?? 'any'}` : 'value: string'}) => {
|
|
150
|
+
clearTimeout(t);
|
|
151
|
+
t = setTimeout(async () => {
|
|
152
|
+
try {
|
|
153
|
+
// Optionally abort before firing a new request
|
|
154
|
+
${abortName}();
|
|
155
|
+
await ${actionName}(${[requestBodyVar ?? '/* body from value */', paramsVar ?? '/* params? */'].join(', ')});
|
|
156
|
+
} catch {}
|
|
157
|
+
}, 250);
|
|
158
|
+
};
|
|
159
|
+
}, [${actionName}, ${abortName}]);
|
|
160
|
+
${"```"}
|
|
161
|
+
|
|
162
|
+
<details><summary>Description</summary>
|
|
163
|
+
<p><strong>Use when</strong> validating as the user types. Debounce to reduce chatter; consider <code>${abortName}()</code> before firing a new call.</p>
|
|
164
|
+
</details>
|
|
165
|
+
|
|
166
|
+
### 4) Guard against races (latest-only)
|
|
167
|
+
${"```tsx"}
|
|
168
|
+
const [${actionName}, ${abortName}] = use${pascalCase(descriptor.name)}Async();
|
|
169
|
+
|
|
170
|
+
const latestOnly = async () => {
|
|
171
|
+
${abortName}();
|
|
172
|
+
return ${actionName}(${callArgs});
|
|
173
|
+
};
|
|
174
|
+
${"```"}
|
|
175
|
+
|
|
176
|
+
<details><summary>Description</summary>
|
|
177
|
+
<p><strong>Use when</strong> only the most recent call should win (search suggestions, live filters).</p>
|
|
178
|
+
</details>
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Full example
|
|
183
|
+
${"```tsx"}
|
|
184
|
+
import { use${pascalCase(descriptor.name)}Async } from '@intrig/react/${descriptor.path}/client';
|
|
185
|
+
import { useCallback } from 'react';
|
|
186
|
+
|
|
187
|
+
function MyComponent() {
|
|
188
|
+
const [${actionName}, ${abortName}] = use${pascalCase(descriptor.name)}Async();
|
|
189
|
+
|
|
190
|
+
const run = useCallback(async () => {
|
|
191
|
+
try {
|
|
192
|
+
const data = await ${actionName}(${callArgs});
|
|
193
|
+
alert(JSON.stringify(data));
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.error('Call failed/aborted', e);
|
|
196
|
+
}
|
|
197
|
+
}, [${actionName}]);
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<>
|
|
201
|
+
<button onClick={run}>Call remote</button>
|
|
202
|
+
<button onClick={${abortName}}>Abort</button>
|
|
203
|
+
</>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
${"```"}
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Gotchas & Tips
|
|
211
|
+
- **No \`NetworkState\`:** async hooks return a Promise, not a state machine.
|
|
212
|
+
- **Abort:** always available; call it to cancel the latest in-flight request.
|
|
213
|
+
- **Errors:** wrap calls with \`try/catch\` to handle network failures or abort errors.
|
|
214
|
+
- **Debounce & throttle:** combine with timers to cut down chatter for typeahead/validators.
|
|
215
|
+
- **Types:** prefer generated \`${responseTypeName}\` and \`${paramsType ?? '...Params'}\` if your build emits them.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Reference: Minimal cheat sheet
|
|
220
|
+
${"```ts"}
|
|
221
|
+
const [fn, abort] = use${pascalCase(descriptor.name)}Async();
|
|
222
|
+
await fn(${callArgs});
|
|
223
|
+
abort(); // optional
|
|
224
|
+
${"```"}
|
|
225
|
+
`
|
|
226
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { reactDownloadHookDocs } from './download-hook';
|
|
2
|
+
|
|
3
|
+
// We use a plain object cast to any to avoid direct dependency on the types from "@intrig/plugin-sdk" in test.
|
|
4
|
+
|
|
5
|
+
describe('reactDownloadHookDocs', () => {
|
|
6
|
+
it('snapshot — simple REST descriptor (no body, no path params)', async () => {
|
|
7
|
+
const descriptor = {
|
|
8
|
+
id: '1',
|
|
9
|
+
name: 'downloadReport',
|
|
10
|
+
type: 'rest' as const,
|
|
11
|
+
source: 'demo_api',
|
|
12
|
+
path: 'reports',
|
|
13
|
+
data: {
|
|
14
|
+
method: 'GET',
|
|
15
|
+
paths: ['/api/reports/download'],
|
|
16
|
+
operationId: 'downloadReport',
|
|
17
|
+
// no requestBody
|
|
18
|
+
// no variables
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const result = await reactDownloadHookDocs(descriptor as any);
|
|
23
|
+
|
|
24
|
+
expect(result.path).toBe('download-hook.md');
|
|
25
|
+
expect(result.content).toMatchSnapshot();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('snapshot — request body only (no path params)', async () => {
|
|
29
|
+
const descriptor = {
|
|
30
|
+
id: '2',
|
|
31
|
+
name: 'downloadCustomReport',
|
|
32
|
+
type: 'rest' as const,
|
|
33
|
+
source: 'demo_api',
|
|
34
|
+
path: 'reports',
|
|
35
|
+
data: {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
paths: ['/api/reports/download-custom'],
|
|
38
|
+
operationId: 'downloadCustomReport',
|
|
39
|
+
requestBody: 'DownloadCustomReportRequest',
|
|
40
|
+
// no variables
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const result = await reactDownloadHookDocs(descriptor as any);
|
|
45
|
+
expect(result.path).toBe('download-hook.md');
|
|
46
|
+
expect(result.content).toMatchSnapshot();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('snapshot — path params only (no request body)', async () => {
|
|
50
|
+
const descriptor = {
|
|
51
|
+
id: '3',
|
|
52
|
+
name: 'downloadFileById',
|
|
53
|
+
type: 'rest' as const,
|
|
54
|
+
source: 'demo_api',
|
|
55
|
+
path: 'files/{id}',
|
|
56
|
+
data: {
|
|
57
|
+
method: 'GET',
|
|
58
|
+
paths: ['/api/files/{id}/download'],
|
|
59
|
+
operationId: 'downloadFileById',
|
|
60
|
+
variables: [
|
|
61
|
+
{ name: 'id', in: 'path', ref: '#/components/schemas/Id' },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const result = await reactDownloadHookDocs(descriptor as any);
|
|
67
|
+
expect(result.path).toBe('download-hook.md');
|
|
68
|
+
expect(result.content).toMatchSnapshot();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('snapshot — request body and path params', async () => {
|
|
72
|
+
const descriptor = {
|
|
73
|
+
id: '4',
|
|
74
|
+
name: 'downloadUserDocument',
|
|
75
|
+
type: 'rest' as const,
|
|
76
|
+
source: 'demo_api',
|
|
77
|
+
path: 'users/{userId}/documents',
|
|
78
|
+
data: {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
paths: ['/api/users/{userId}/documents/download'],
|
|
81
|
+
operationId: 'downloadUserDocument',
|
|
82
|
+
requestBody: 'DownloadUserDocumentRequest',
|
|
83
|
+
variables: [
|
|
84
|
+
{ name: 'userId', in: 'PATH', ref: '#/components/schemas/UserId' },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const result = await reactDownloadHookDocs(descriptor as any);
|
|
90
|
+
expect(result.path).toBe('download-hook.md');
|
|
91
|
+
expect(result.content).toMatchSnapshot();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('handles multiple path params', async () => {
|
|
95
|
+
const descriptor = {
|
|
96
|
+
id: '5',
|
|
97
|
+
name: 'downloadProjectAsset',
|
|
98
|
+
type: 'rest' as const,
|
|
99
|
+
source: 'demo_api',
|
|
100
|
+
path: 'projects/{projectId}/assets/{assetId}',
|
|
101
|
+
data: {
|
|
102
|
+
method: 'GET',
|
|
103
|
+
paths: ['/api/projects/{projectId}/assets/{assetId}/download'],
|
|
104
|
+
operationId: 'downloadProjectAsset',
|
|
105
|
+
variables: [
|
|
106
|
+
{ name: 'projectId', in: 'path', ref: '#/components/schemas/ProjectId' },
|
|
107
|
+
{ name: 'assetId', in: 'PATH', ref: '#/components/schemas/AssetId' },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const result = await reactDownloadHookDocs(descriptor as any);
|
|
113
|
+
expect(result.path).toBe('download-hook.md');
|
|
114
|
+
expect(result.content).toMatchSnapshot();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('handles query params mixed with path params', async () => {
|
|
118
|
+
const descriptor = {
|
|
119
|
+
id: '6',
|
|
120
|
+
name: 'downloadTaskFile',
|
|
121
|
+
type: 'rest' as const,
|
|
122
|
+
source: 'demo_api',
|
|
123
|
+
path: 'tasks/{taskId}/files',
|
|
124
|
+
data: {
|
|
125
|
+
method: 'GET',
|
|
126
|
+
paths: ['/api/tasks/{taskId}/files/download'],
|
|
127
|
+
operationId: 'downloadTaskFile',
|
|
128
|
+
variables: [
|
|
129
|
+
{ name: 'taskId', in: 'path', ref: '#/components/schemas/TaskId' },
|
|
130
|
+
{ name: 'format', in: 'query', ref: '#/components/schemas/Format' },
|
|
131
|
+
{ name: 'includeMetadata', in: 'QUERY', ref: '#/components/schemas/Boolean' },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const result = await reactDownloadHookDocs(descriptor as any);
|
|
137
|
+
expect(result.path).toBe('download-hook.md');
|
|
138
|
+
expect(result.content).toMatchSnapshot();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('handles empty variables array', async () => {
|
|
142
|
+
const descriptor = {
|
|
143
|
+
id: '7',
|
|
144
|
+
name: 'downloadBackup',
|
|
145
|
+
type: 'rest' as const,
|
|
146
|
+
source: 'demo_api',
|
|
147
|
+
path: 'backup',
|
|
148
|
+
data: {
|
|
149
|
+
method: 'GET',
|
|
150
|
+
paths: ['/api/backup/download'],
|
|
151
|
+
operationId: 'downloadBackup',
|
|
152
|
+
variables: [], // empty array instead of undefined
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const result = await reactDownloadHookDocs(descriptor as any);
|
|
157
|
+
expect(result.path).toBe('download-hook.md');
|
|
158
|
+
expect(result.content).toMatchSnapshot();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles case-insensitive path parameter detection', async () => {
|
|
162
|
+
const descriptor = {
|
|
163
|
+
id: '8',
|
|
164
|
+
name: 'downloadInvoice',
|
|
165
|
+
type: 'rest' as const,
|
|
166
|
+
source: 'demo_api',
|
|
167
|
+
path: 'invoices/{invoiceId}',
|
|
168
|
+
data: {
|
|
169
|
+
method: 'GET',
|
|
170
|
+
paths: ['/api/invoices/{invoiceId}/download'],
|
|
171
|
+
operationId: 'downloadInvoice',
|
|
172
|
+
variables: [
|
|
173
|
+
{ name: 'invoiceId', in: 'Path', ref: '#/components/schemas/InvoiceId' }, // Mixed case
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const result = await reactDownloadHookDocs(descriptor as any);
|
|
179
|
+
expect(result.path).toBe('download-hook.md');
|
|
180
|
+
expect(result.content).toMatchSnapshot();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { camelCase, mdLiteral, pascalCase, ResourceDescriptor, RestData } from "@intrig/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
export function reactDownloadHookDocs(descriptor: ResourceDescriptor<RestData>) {
|
|
4
|
+
const md = mdLiteral('download-hook.md');
|
|
5
|
+
|
|
6
|
+
// ===== Derived names (preserve these) =====
|
|
7
|
+
const hasPathParams = (descriptor.data.variables ?? []).some(
|
|
8
|
+
(v: any) => v.in?.toUpperCase() === 'PATH',
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const actionName = camelCase(descriptor.name); // e.g. downloadTaskFile
|
|
12
|
+
const respVar = `${actionName}Resp`; // e.g. downloadTaskFileResp
|
|
13
|
+
|
|
14
|
+
const paramsVar = hasPathParams ? `${actionName}Params` : undefined; // e.g. downloadTaskFileParams
|
|
15
|
+
const paramsType = hasPathParams ? `${pascalCase(descriptor.name)}Params` : undefined; // e.g. DownloadTaskFileParams
|
|
16
|
+
|
|
17
|
+
const pascal = pascalCase(descriptor.name);
|
|
18
|
+
const responseTypeName = `${pascal}ResponseBody`; // e.g. DownloadTaskFileResponseBody
|
|
19
|
+
|
|
20
|
+
return md`
|
|
21
|
+
# Intrig Download Hooks — Quick Guide
|
|
22
|
+
|
|
23
|
+
## Copy-paste starter (fast lane)
|
|
24
|
+
|
|
25
|
+
### Auto-download (most common)
|
|
26
|
+
${"```ts"}
|
|
27
|
+
import { use${pascal}Download } from '@intrig/react/${descriptor.path}/client';
|
|
28
|
+
${"```"}
|
|
29
|
+
${"```ts"}
|
|
30
|
+
import { isPending, isError } from '@intrig/react';
|
|
31
|
+
${"```"}
|
|
32
|
+
${"```tsx"}
|
|
33
|
+
const [${respVar}, ${actionName}] = use${pascal}Download({ clearOnUnmount: true });
|
|
34
|
+
// e.g., in a click handler:
|
|
35
|
+
${actionName}(${paramsType ? paramsVar ?? '{}' : '{}'});
|
|
36
|
+
${"```"}
|
|
37
|
+
|
|
38
|
+
### Manual/stateful (you handle the blob/UI)
|
|
39
|
+
${"```ts"}
|
|
40
|
+
import { use${pascal} } from '@intrig/react/${descriptor.path}/client';
|
|
41
|
+
${"```"}
|
|
42
|
+
${"```ts"}
|
|
43
|
+
import { isSuccess, isPending, isError } from '@intrig/react';
|
|
44
|
+
${"```"}
|
|
45
|
+
${"```tsx"}
|
|
46
|
+
const [${respVar}, ${actionName}] = use${pascal}({ clearOnUnmount: true });
|
|
47
|
+
// later:
|
|
48
|
+
${actionName}(${paramsType ? paramsVar ?? '{}' : '{}'});
|
|
49
|
+
${"```"}
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## TL;DR (auto-download)
|
|
54
|
+
${"```tsx"}
|
|
55
|
+
import { use${pascal}Download } from '@intrig/react/${descriptor.path}/client';
|
|
56
|
+
import { isPending, isError } from '@intrig/react';
|
|
57
|
+
|
|
58
|
+
export default function Example() {
|
|
59
|
+
const [${respVar}, ${actionName}] = use${pascal}Download({ clearOnUnmount: true });
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<button
|
|
63
|
+
onClick={() => ${actionName}(${paramsType ? paramsVar ?? '{}' : '{}'})}
|
|
64
|
+
disabled={isPending(${respVar})}
|
|
65
|
+
>
|
|
66
|
+
{isPending(${respVar}) ? 'Downloading…' : 'Download'}
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
${"```"}
|
|
71
|
+
|
|
72
|
+
${paramsType ? `### Optional types (if generated by your build)
|
|
73
|
+
${"```ts"}
|
|
74
|
+
import type { ${paramsType} } from '@intrig/react/${descriptor.path}/${pascal}.params';
|
|
75
|
+
import type { ${responseTypeName} } from '@intrig/react/${descriptor.path}/${pascal}.response';
|
|
76
|
+
${"```"}
|
|
77
|
+
` : ''}
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Hook APIs
|
|
82
|
+
|
|
83
|
+
### \`use${pascal}Download\` (auto-download)
|
|
84
|
+
- **What it does:** requests the file with \`responseType: 'blob'\` + \`adapter: 'fetch'\`, derives filename from \`Content-Disposition\` if present, creates a temporary object URL, clicks a hidden \`<a>\`, **downloads**, then resets state to \`init\`.
|
|
85
|
+
- **Signature:** \`[state, download, clear]\`
|
|
86
|
+
- \`download(params: ${paramsType ?? 'Record<string, unknown>'}) => void\`
|
|
87
|
+
|
|
88
|
+
### \`use${pascal}\` (manual/stateful)
|
|
89
|
+
- **What it does:** same request but **does not** auto-save. You control preview/saving using \`state.data\` + \`state.headers\`.
|
|
90
|
+
- **Signature:** \`[state, fetchFile, clear]\`
|
|
91
|
+
- \`fetchFile(params: ${paramsType ?? 'Record<string, unknown>'}) => void\`
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Usage Patterns
|
|
96
|
+
|
|
97
|
+
### 1) Auto-download on click (recommended)
|
|
98
|
+
${"```tsx"}
|
|
99
|
+
const [${respVar}, ${actionName}] = use${pascal}Download({ clearOnUnmount: true });
|
|
100
|
+
|
|
101
|
+
<button
|
|
102
|
+
onClick={() => ${actionName}(${paramsType ? paramsVar ?? '{}' : '{}'})}
|
|
103
|
+
disabled={isPending(${respVar})}
|
|
104
|
+
>
|
|
105
|
+
{isPending(${respVar}) ? 'Downloading…' : 'Download file'}
|
|
106
|
+
</button>
|
|
107
|
+
{isError(${respVar}) ? <p className="text-red-500">Failed to download.</p> : null}
|
|
108
|
+
${"```"}
|
|
109
|
+
|
|
110
|
+
<details><summary>Description</summary>
|
|
111
|
+
<p>Most users just need a button that saves the file. This variant handles object URL creation, filename extraction, click, and state reset.</p>
|
|
112
|
+
</details>
|
|
113
|
+
|
|
114
|
+
### 2) Auto-download on mount (e.g., “Your file is ready” page)
|
|
115
|
+
${"```tsx"}
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
${actionName}(${paramsType ? paramsVar ?? '{}' : '{}'});
|
|
118
|
+
}, [${actionName}]);
|
|
119
|
+
${"```"}
|
|
120
|
+
|
|
121
|
+
<details><summary>Description</summary>
|
|
122
|
+
<p>Good for post-processing routes that immediately start a download.</p>
|
|
123
|
+
</details>
|
|
124
|
+
|
|
125
|
+
### 3) Manual handling (preview or custom filename)
|
|
126
|
+
${"```tsx"}
|
|
127
|
+
const [${respVar}, ${actionName}] = use${pascal}({ clearOnUnmount: true });
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (isSuccess(${respVar})) {
|
|
131
|
+
const ct = ${respVar}.headers?.['content-type'] ?? 'application/octet-stream';
|
|
132
|
+
const parts = Array.isArray(${respVar}.data) ? ${respVar}.data : [${respVar}.data];
|
|
133
|
+
const url = URL.createObjectURL(new Blob(parts, { type: ct }));
|
|
134
|
+
// preview/save with your own UI...
|
|
135
|
+
return () => URL.revokeObjectURL(url);
|
|
136
|
+
}
|
|
137
|
+
}, [${respVar}]);
|
|
138
|
+
${"```"}
|
|
139
|
+
|
|
140
|
+
<details><summary>Description</summary>
|
|
141
|
+
<p>Use when you need to inspect headers, show a preview, or control the filename/UI flow.</p>
|
|
142
|
+
</details>
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Behavior notes (what the auto-download variant does)
|
|
147
|
+
- Forces \`responseType: 'blob'\` and \`adapter: 'fetch'\`.
|
|
148
|
+
- If \`content-type\` is JSON, stringifies payload so the saved file is human-readable.
|
|
149
|
+
- Parses \`Content-Disposition\` to derive a filename; falls back to a default.
|
|
150
|
+
- Creates and clicks a temporary link, then **resets state to \`init\`**.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Gotchas & Tips
|
|
155
|
+
- **Expose headers in CORS:** server should send
|
|
156
|
+
\`Access-Control-Expose-Headers: Content-Disposition, Content-Type\`
|
|
157
|
+
- **Disable double clicks:** guard with \`isPending(state)\`.
|
|
158
|
+
- **Revoke URLs** when you create them manually in the stateful variant.
|
|
159
|
+
- **iOS Safari:** blob downloads may open a new tab—consider server-side direct-download URLs for a smoother UX.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Troubleshooting
|
|
164
|
+
- **No filename shown:** your server didn’t include \`Content-Disposition\`. Add it.
|
|
165
|
+
- **Got JSON instead of a file:** server returned \`application/json\` (maybe an error). The auto hook saves it as text; surface the error in UI.
|
|
166
|
+
- **Nothing happens on click:** ensure you’re using the **Download** variant and the request succeeds (check Network tab); verify CORS headers.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
`;
|
|
170
|
+
}
|