codz-cli 0.0.0-stage → 0.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/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +523 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +358 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// React Native screens: route add, screen add (data) and screen add --form.
|
|
2
|
+
//
|
|
3
|
+
// React Navigation <Name>Screen.tsx in the screens folder, an entry in
|
|
4
|
+
// RootStackParamList and a <Stack.Screen> in the navigator;
|
|
5
|
+
// path parameters (:id) become route params
|
|
6
|
+
// Expo Router a file under app/ ([id] for parameters), read with
|
|
7
|
+
// useLocalSearchParams
|
|
8
|
+
//
|
|
9
|
+
// A data screen loads the endpoint (through its TanStack Query hook when the
|
|
10
|
+
// project has one) and shows loading, the error with Retry, and the data
|
|
11
|
+
// (FlatList for lists). A form screen has one input per request field.
|
|
12
|
+
import { join, posix } from 'node:path';
|
|
13
|
+
import { importPath } from "../adapters/nextjs.js";
|
|
14
|
+
import { splitTsParams } from "../adapters/reactQuery.js";
|
|
15
|
+
import { buildChangeSet } from "../core/changeset.js";
|
|
16
|
+
import { exists, readText, walk } from "../core/fsutil.js";
|
|
17
|
+
import { ToolError } from "../core/types.js";
|
|
18
|
+
import { camel, kebab, pascal, words } from "../input/naming.js";
|
|
19
|
+
import { findDeclaration } from "./apiEdit.js";
|
|
20
|
+
import { responseShape, titleField } from "./screen.js";
|
|
21
|
+
import { reactPrefill } from "./prefill.js";
|
|
22
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
23
|
+
function routeParamsOf(path) {
|
|
24
|
+
return [...(path ?? '').matchAll(/[:{[]([\w-]+)[\]}]?/g)].map((m) => camel(m[1]));
|
|
25
|
+
}
|
|
26
|
+
function names(name) {
|
|
27
|
+
const base = pascal(name.replace(/[-_ ]?screen$/i, ''));
|
|
28
|
+
return { route: base, component: `${base}Screen`, title: words(base).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ') };
|
|
29
|
+
}
|
|
30
|
+
function place(root, map, opts, warnings) {
|
|
31
|
+
const c = map.conventions;
|
|
32
|
+
const params = routeParamsOf(opts.path);
|
|
33
|
+
const n = names(opts.name);
|
|
34
|
+
if (c.router === 'expo-router') {
|
|
35
|
+
const dir = c.routerDir || 'app';
|
|
36
|
+
const segs = (opts.path ?? `/${kebab(n.route)}`).split('/').filter(Boolean).map((s) => s.replace(/^:([\w-]+)$/, '[$1]').replace(/^\{([\w-]+)\}$/, '[$1]'));
|
|
37
|
+
const file = posix.join(dir, ...segs) + '.tsx';
|
|
38
|
+
return {
|
|
39
|
+
file,
|
|
40
|
+
paramsCode: (ps) => (ps.length ? { imports: [`import { useLocalSearchParams } from 'expo-router';`], lines: [` const { ${ps.join(', ')} } = useLocalSearchParams<{ ${ps.map((p) => `${p}: string`).join('; ')} }>();`], signature: '' } : { imports: [], lines: [], signature: '' }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (c.router !== 'react-navigation')
|
|
44
|
+
throw new ToolError('not_supported', 'This project has no navigation library, so codz cannot add a screen to it.', 'Run codz setup (it adds React Navigation), or add @react-navigation/native or expo-router.');
|
|
45
|
+
const navFile = c.navigatorFile;
|
|
46
|
+
const navText = navFile ? readText(join(root, navFile)) : null;
|
|
47
|
+
if (!navFile || navText === null)
|
|
48
|
+
throw new ToolError('no_navigator', `The navigator ${navFile || '(none)'} was not found.`, 'Set navigatorFile in codz.config.yaml to the file with your Stack.Navigator.');
|
|
49
|
+
const file = `${c.screensFolder || 'src/screens'}/${n.component}.tsx`;
|
|
50
|
+
const typed = /RootStackParamList/.test(navText);
|
|
51
|
+
if (!typed && params.length)
|
|
52
|
+
warnings.push(`${navFile} has no RootStackParamList, so route.params is not typed.`);
|
|
53
|
+
const navImport = importPath(file, navFile, { ...c, importStyle: 'relative' });
|
|
54
|
+
return {
|
|
55
|
+
file,
|
|
56
|
+
navigator: { file: navFile, text: navText },
|
|
57
|
+
paramsCode: (ps) => ps.length
|
|
58
|
+
? typed
|
|
59
|
+
? { imports: [`import type { NativeStackScreenProps } from '@react-navigation/native-stack';`, `import type { RootStackParamList } from '${navImport}';`], lines: [` const { ${ps.join(', ')} } = route.params;`], signature: `{ route }: NativeStackScreenProps<RootStackParamList, '${n.route}'>` }
|
|
60
|
+
: { imports: [], lines: [` const { ${ps.join(', ')} } = route.params as { ${ps.map((p) => `${p}: string`).join('; ')} };`], signature: '{ route }: { route: { params: unknown } }' }
|
|
61
|
+
: { imports: [], lines: [], signature: '' },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Adds the screen to RootStackParamList and the navigator. */
|
|
65
|
+
function register(nav, file, opts, map) {
|
|
66
|
+
const n = names(opts.name);
|
|
67
|
+
const params = routeParamsOf(opts.path);
|
|
68
|
+
let t = nav.text;
|
|
69
|
+
if (new RegExp(`name=["']${n.route}["']`).test(t))
|
|
70
|
+
throw new ToolError('route_exists', `${nav.file} already has a screen called ${n.route}.`);
|
|
71
|
+
const semi = map.conventions.semicolons === 'false' ? '' : ';';
|
|
72
|
+
const pl = /(export\s+type\s+RootStackParamList\s*=\s*\{)([\s\S]*?)(\n\}\s*;?)/.exec(t);
|
|
73
|
+
if (pl)
|
|
74
|
+
t = t.replace(pl[0], `${pl[1]}${pl[2]}\n ${n.route}: ${params.length ? `{ ${params.map((p) => `${p}: string`).join('; ')} }` : 'undefined'}${semi}${pl[3]}`);
|
|
75
|
+
const close = /\n([ \t]*)<\/(\w+)\.Navigator>/.exec(t);
|
|
76
|
+
if (!close)
|
|
77
|
+
throw new ToolError('no_navigator', `Could not find </...Navigator> in ${nav.file}.`);
|
|
78
|
+
t = t.replace(close[0], `\n${close[1]} <${close[2]}.Screen name="${n.route}" component={${n.component}} />${close[0]}`);
|
|
79
|
+
const imp = `import ${n.component} from '${importPath(nav.file, file, { ...map.conventions, importStyle: 'relative' }).replace(/\.tsx$/, '')}'${semi}`;
|
|
80
|
+
const imports = [...t.matchAll(/^import[^\n]*\n/gm)];
|
|
81
|
+
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
|
|
82
|
+
return t.slice(0, at) + imp + '\n' + t.slice(at);
|
|
83
|
+
}
|
|
84
|
+
function navHint(map, opts) {
|
|
85
|
+
const n = names(opts.name);
|
|
86
|
+
const params = routeParamsOf(opts.path);
|
|
87
|
+
if (map.conventions.router === 'expo-router')
|
|
88
|
+
return `Open it with: router.push(\`${(opts.path ?? `/${kebab(n.route)}`).replace(/:(\w+)/g, '${$1}')}\`)`;
|
|
89
|
+
return `Open it with: navigation.navigate('${n.route}'${params.length ? `, { ${params.map((p) => `${p}: '...'`).join(', ')} }` : ''})`;
|
|
90
|
+
}
|
|
91
|
+
function finish(root, skill, files, summary, warnings) {
|
|
92
|
+
return buildChangeSet(root, skill, { files, summary, warnings, generated: [] });
|
|
93
|
+
}
|
|
94
|
+
export function planRnRoute(root, map, opts) {
|
|
95
|
+
const warnings = [];
|
|
96
|
+
const p = place(root, map, opts, warnings);
|
|
97
|
+
if (exists(join(root, p.file)))
|
|
98
|
+
throw new ToolError('route_exists', `${p.file} already exists.`);
|
|
99
|
+
const n = names(opts.name);
|
|
100
|
+
const params = routeParamsOf(opts.path);
|
|
101
|
+
const pc = p.paramsCode(params);
|
|
102
|
+
const body = [
|
|
103
|
+
`import { StyleSheet, Text, View } from 'react-native';`,
|
|
104
|
+
...pc.imports,
|
|
105
|
+
'',
|
|
106
|
+
`export default function ${n.component}(${pc.signature}) {`,
|
|
107
|
+
...pc.lines,
|
|
108
|
+
' return (',
|
|
109
|
+
' <View style={styles.container}>',
|
|
110
|
+
` <Text style={styles.title}>${n.title}${params.length ? ` {${params[0]}}` : ''}</Text>`,
|
|
111
|
+
' </View>',
|
|
112
|
+
' );',
|
|
113
|
+
'}',
|
|
114
|
+
'',
|
|
115
|
+
'const styles = StyleSheet.create({',
|
|
116
|
+
" container: { flex: 1, alignItems: 'center', justifyContent: 'center' },",
|
|
117
|
+
" title: { fontSize: 20, fontWeight: '600' },",
|
|
118
|
+
'});',
|
|
119
|
+
'',
|
|
120
|
+
].join('\n');
|
|
121
|
+
const files = new Map([[p.file, body]]);
|
|
122
|
+
const summary = [`Create ${p.file}: ${n.component}`];
|
|
123
|
+
if (p.navigator) {
|
|
124
|
+
files.set(p.navigator.file, register(p.navigator, p.file, opts, map));
|
|
125
|
+
summary.push(`Update ${p.navigator.file}: add ${n.route} to RootStackParamList and the stack`);
|
|
126
|
+
}
|
|
127
|
+
summary.push(navHint(map, opts));
|
|
128
|
+
return finish(root, 'route add', files, summary, warnings);
|
|
129
|
+
}
|
|
130
|
+
/** The endpoint's arguments, filled from route params by name (number params are converted). */
|
|
131
|
+
function callArgs(root, ep, params, skip = () => false) {
|
|
132
|
+
const decl = findDeclaration(readText(join(root, ep.file)) ?? '', ep.name, 'ts');
|
|
133
|
+
if (!decl)
|
|
134
|
+
throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
|
|
135
|
+
const ps = splitTsParams(decl.params);
|
|
136
|
+
const missing = ps.filter((p) => !p.optional && !skip(p.name) && !params.includes(p.name)).map((p) => p.name);
|
|
137
|
+
const args = ps.filter((p) => !skip(p.name) && params.includes(p.name)).map((p) => (/^number$/.test(p.type) ? `Number(${p.name})` : p.name));
|
|
138
|
+
return { args: args.join(', '), missing, decl };
|
|
139
|
+
}
|
|
140
|
+
function findEp(map, name) {
|
|
141
|
+
const ep = map.endpoints.find((e) => e.name === name) ?? map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
142
|
+
if (!ep)
|
|
143
|
+
throw new ToolError('no_endpoint', `No endpoint called ${name} was found.`, map.endpoints.length ? `Endpoints: ${map.endpoints.map((e) => e.name).join(', ')}` : 'Add one first with: codz api add');
|
|
144
|
+
return ep;
|
|
145
|
+
}
|
|
146
|
+
/** The TanStack Query hook for an endpoint, if the project has one. */
|
|
147
|
+
function hookFor(root, map, ep) {
|
|
148
|
+
if (map.conventions.dataFetching !== 'react-query' || !map.conventions.hooksFolder)
|
|
149
|
+
return null;
|
|
150
|
+
for (const f of walk(root, map.conventions.hooksFolder, { exts: ['.ts', '.tsx'] })) {
|
|
151
|
+
const t = readText(join(root, f)) ?? '';
|
|
152
|
+
const m = new RegExp(`export function (use\\w+)\\([^)]*\\)\\s*\\{\\s*return useQuery\\(\\{[^}]*?queryFn: \\(\\) => ${ep.name}\\(`).exec(t);
|
|
153
|
+
if (m)
|
|
154
|
+
return { name: m[1], file: f };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
export function planRnScreen(root, map, opts) {
|
|
159
|
+
if (!opts.api)
|
|
160
|
+
return planRnRoute(root, map, opts);
|
|
161
|
+
const ep = findEp(map, opts.api);
|
|
162
|
+
if (opts.form)
|
|
163
|
+
return planRnForm(root, map, opts, ep);
|
|
164
|
+
if (ep.method !== 'GET')
|
|
165
|
+
throw new ToolError('screen_needs_get', `${ep.name} is a ${ep.method} endpoint; a screen loads data with a GET.`, `For a form that sends it, add --form.`);
|
|
166
|
+
const warnings = [];
|
|
167
|
+
const p = place(root, map, opts, warnings);
|
|
168
|
+
if (exists(join(root, p.file)))
|
|
169
|
+
throw new ToolError('route_exists', `${p.file} already exists.`);
|
|
170
|
+
const n = names(opts.name);
|
|
171
|
+
const params = routeParamsOf(opts.path);
|
|
172
|
+
const { args, missing, decl } = callArgs(root, ep, params);
|
|
173
|
+
if (missing.length)
|
|
174
|
+
throw new ToolError('screen_args', `${ep.name}() needs ${missing.join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${kebab(n.route)}/:${missing[0]}`);
|
|
175
|
+
const shape = responseShape(decl.returnType, map);
|
|
176
|
+
const tf = titleField(shape.model);
|
|
177
|
+
const idf = shape.model?.fields.find((f) => /^(id|uuid|key|slug)$/.test(f.name))?.name;
|
|
178
|
+
const pc = p.paramsCode(params);
|
|
179
|
+
const hook = hookFor(root, map, ep);
|
|
180
|
+
const c = map.conventions;
|
|
181
|
+
const dir = posix.dirname(ep.file);
|
|
182
|
+
const barrel = [`${dir}/index.ts`].find((f) => exists(join(root, f)) && /export\s+\*\s+from/.test(readText(join(root, f)) ?? ''));
|
|
183
|
+
const fromApi = importPath(p.file, barrel ?? ep.file, c).replace(/\.tsx?$/, '');
|
|
184
|
+
const T = shape.raw || 'unknown';
|
|
185
|
+
const access = (v, f) => (IDENT.test(f) ? `${v}.${f}` : `${v}['${f}']`);
|
|
186
|
+
const dataView = shape.list
|
|
187
|
+
? [
|
|
188
|
+
' <FlatList',
|
|
189
|
+
' data={data}',
|
|
190
|
+
` keyExtractor={(item, index) => ${idf ? `String(${access('item', idf)})` : 'String(index)'}}`,
|
|
191
|
+
' refreshing={loading}',
|
|
192
|
+
' onRefresh={load}',
|
|
193
|
+
" ListEmptyComponent={<Text style={styles.empty}>Nothing here yet.</Text>}",
|
|
194
|
+
` renderItem={({ item }) => <Text style={styles.row}>{${tf ? `String(${access('item', tf)})` : 'JSON.stringify(item)'}}</Text>}`,
|
|
195
|
+
' />',
|
|
196
|
+
]
|
|
197
|
+
: [
|
|
198
|
+
' <ScrollView contentContainerStyle={styles.details}>',
|
|
199
|
+
...(shape.model?.fields.length
|
|
200
|
+
? shape.model.fields.flatMap((f) => [` <Text style={styles.label}>${f.name}</Text>`, ` <Text style={styles.value}>{String(${access('data', f.name)} ?? '')}</Text>`])
|
|
201
|
+
: [' <Text>{JSON.stringify(data, null, 2)}</Text>']),
|
|
202
|
+
' </ScrollView>',
|
|
203
|
+
];
|
|
204
|
+
const stateLines = hook
|
|
205
|
+
? [` const { data, isLoading: loading, error, refetch: load } = ${hook.name}(${args});`]
|
|
206
|
+
: [
|
|
207
|
+
` const [data, setData] = useState<${T} | null>(null);`,
|
|
208
|
+
' const [loading, setLoading] = useState(true);',
|
|
209
|
+
' const [error, setError] = useState<unknown>(null);',
|
|
210
|
+
'',
|
|
211
|
+
' const load = useCallback(async () => {',
|
|
212
|
+
' setLoading(true);',
|
|
213
|
+
' setError(null);',
|
|
214
|
+
' try {',
|
|
215
|
+
` setData(await ${ep.name}(${args}));`,
|
|
216
|
+
' } catch (e) {',
|
|
217
|
+
' setError(e);',
|
|
218
|
+
' } finally {',
|
|
219
|
+
' setLoading(false);',
|
|
220
|
+
' }',
|
|
221
|
+
` }, [${params.join(', ')}]);`,
|
|
222
|
+
'',
|
|
223
|
+
' useEffect(() => {',
|
|
224
|
+
' load();',
|
|
225
|
+
' }, [load]);',
|
|
226
|
+
];
|
|
227
|
+
const body = [
|
|
228
|
+
...(hook ? [] : [`import { useCallback, useEffect, useState } from 'react';`]),
|
|
229
|
+
`import { ActivityIndicator, Button, ${shape.list ? 'FlatList' : 'ScrollView'}, StyleSheet, Text, View } from 'react-native';`,
|
|
230
|
+
...pc.imports,
|
|
231
|
+
hook ? `import { ${hook.name} } from '${importPath(p.file, hook.file, c).replace(/\.tsx?$/, '')}';` : `import { ${ep.name} } from '${fromApi}';`,
|
|
232
|
+
...(shape.model && !hook ? [`import type { ${shape.model.name} } from '${importPath(p.file, shape.model.file, c).replace(/\.tsx?$/, '')}';`] : []),
|
|
233
|
+
'',
|
|
234
|
+
`export default function ${n.component}(${pc.signature}) {`,
|
|
235
|
+
...pc.lines,
|
|
236
|
+
...stateLines,
|
|
237
|
+
'',
|
|
238
|
+
' if (loading && !data) {',
|
|
239
|
+
' return (',
|
|
240
|
+
' <View style={styles.center}>',
|
|
241
|
+
' <ActivityIndicator />',
|
|
242
|
+
' </View>',
|
|
243
|
+
' );',
|
|
244
|
+
' }',
|
|
245
|
+
' if (error) {',
|
|
246
|
+
' return (',
|
|
247
|
+
' <View style={styles.center}>',
|
|
248
|
+
` <Text style={styles.error}>{error instanceof Error ? error.message : String(error)}</Text>`,
|
|
249
|
+
' <Button title="Retry" onPress={() => load()} />',
|
|
250
|
+
' </View>',
|
|
251
|
+
' );',
|
|
252
|
+
' }',
|
|
253
|
+
...(shape.list ? [] : [' if (!data) return null;']),
|
|
254
|
+
' return (',
|
|
255
|
+
...dataView,
|
|
256
|
+
' );',
|
|
257
|
+
'}',
|
|
258
|
+
'',
|
|
259
|
+
'const styles = StyleSheet.create({',
|
|
260
|
+
" center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 },",
|
|
261
|
+
" error: { marginBottom: 12, textAlign: 'center' },",
|
|
262
|
+
" empty: { padding: 16, textAlign: 'center' },",
|
|
263
|
+
" row: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ccc' },",
|
|
264
|
+
" details: { padding: 16 },",
|
|
265
|
+
" label: { fontSize: 12, color: '#666', marginTop: 12 },",
|
|
266
|
+
" value: { fontSize: 16 },",
|
|
267
|
+
'});',
|
|
268
|
+
'',
|
|
269
|
+
].join('\n');
|
|
270
|
+
const files = new Map([[p.file, body]]);
|
|
271
|
+
const summary = [`Create ${p.file}: ${n.component} loads ${hook ? `${hook.name}()` : `${ep.name}()`} and shows loading, the error with Retry, and the data`];
|
|
272
|
+
if (p.navigator) {
|
|
273
|
+
files.set(p.navigator.file, register(p.navigator, p.file, opts, map));
|
|
274
|
+
summary.push(`Update ${p.navigator.file}: add ${n.route} to RootStackParamList and the stack`);
|
|
275
|
+
}
|
|
276
|
+
summary.push(navHint(map, opts));
|
|
277
|
+
return finish(root, 'screen add', files, summary, warnings);
|
|
278
|
+
}
|
|
279
|
+
function kind(type) {
|
|
280
|
+
const t = type.replace(/\s*\|\s*null$/, '').trim();
|
|
281
|
+
if (t === 'string')
|
|
282
|
+
return 'text';
|
|
283
|
+
if (t === 'number')
|
|
284
|
+
return 'number';
|
|
285
|
+
if (t === 'boolean')
|
|
286
|
+
return 'bool';
|
|
287
|
+
if (t === 'string[]')
|
|
288
|
+
return 'strings';
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
function planRnForm(root, map, opts, ep) {
|
|
292
|
+
if (ep.method === 'GET' || ep.method === 'HEAD')
|
|
293
|
+
throw new ToolError('form_needs_body', `${ep.name} is a ${ep.method} endpoint; a form sends data with POST, PUT or PATCH.`);
|
|
294
|
+
const warnings = [];
|
|
295
|
+
const p = place(root, map, opts, warnings);
|
|
296
|
+
if (exists(join(root, p.file)))
|
|
297
|
+
throw new ToolError('route_exists', `${p.file} already exists.`);
|
|
298
|
+
const n = names(opts.name);
|
|
299
|
+
const params = routeParamsOf(opts.path);
|
|
300
|
+
const { args, missing, decl } = callArgs(root, ep, params, (x) => x === 'body');
|
|
301
|
+
if (missing.length)
|
|
302
|
+
throw new ToolError('screen_args', `${ep.name}() needs ${missing.join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${kebab(n.route)}/:${missing[0]}`);
|
|
303
|
+
const body = splitTsParams(decl.params).find((x) => x.name === 'body');
|
|
304
|
+
if (!body)
|
|
305
|
+
throw new ToolError('form_needs_body', `${ep.name}() takes no request body, so there is nothing for a form to send.`);
|
|
306
|
+
const partial = /^Partial</.test(body.type);
|
|
307
|
+
const model = map.models.find((m) => m.name === body.type.replace(/^Partial<(.+)>$/, '$1'));
|
|
308
|
+
if (!model)
|
|
309
|
+
throw new ToolError('form_fields', `The request type ${body.type} is not a model codz knows, so it cannot build the inputs.`);
|
|
310
|
+
const fields = model.fields.map((f) => ({ f, k: kind(f.type), required: !partial && !f.nullable }));
|
|
311
|
+
const cannot = fields.filter((x) => !x.k && x.required).map((x) => `${x.f.name} (${x.f.type})`);
|
|
312
|
+
if (cannot.length)
|
|
313
|
+
throw new ToolError('form_fields', `${model.name} has required fields a form cannot fill: ${cannot.join(', ')}.`, `Write the form for ${ep.name} by hand, or make those fields optional.`);
|
|
314
|
+
const usable = fields.filter((x) => x.k);
|
|
315
|
+
const key = (s) => (IDENT.test(s) ? s : `'${s}'`);
|
|
316
|
+
const get = (s) => (IDENT.test(s) ? `values.${s}` : `values['${s}']`);
|
|
317
|
+
const value = (x) => {
|
|
318
|
+
const v = get(x.f.name);
|
|
319
|
+
const conv = x.k === 'number' ? `Number(${v})` : x.k === 'strings' ? `${v}.split(',').map((s) => s.trim()).filter(Boolean)` : v;
|
|
320
|
+
if (x.k === 'bool' || x.required)
|
|
321
|
+
return conv;
|
|
322
|
+
return `${v} === '' ? undefined : ${conv}`;
|
|
323
|
+
};
|
|
324
|
+
const pc = p.paramsCode(params);
|
|
325
|
+
const c = map.conventions;
|
|
326
|
+
const dir = posix.dirname(ep.file);
|
|
327
|
+
const barrel = [`${dir}/index.ts`].find((f) => exists(join(root, f)) && /export\s+\*\s+from/.test(readText(join(root, f)) ?? ''));
|
|
328
|
+
const pre = reactPrefill(root, map, opts.load, usable.map((x) => ({ json: x.f.name, kind: x.k })), params, key);
|
|
329
|
+
const apiFrom = importPath(p.file, barrel ?? ep.file, c).replace(/\.tsx?$/, '');
|
|
330
|
+
const preFrom = pre ? importPath(p.file, barrel ?? pre.ep.file, c).replace(/\.tsx?$/, '') : '';
|
|
331
|
+
const text = [
|
|
332
|
+
`import { ${pre ? 'useEffect, ' : ''}useState } from 'react';`,
|
|
333
|
+
`import { Alert, Button, ScrollView, StyleSheet, Switch, Text, TextInput, View } from 'react-native';`,
|
|
334
|
+
...pc.imports,
|
|
335
|
+
...(pre && preFrom === apiFrom ? [`import { ${[ep.name, pre.ep.name].sort().join(', ')} } from '${apiFrom}';`] : [`import { ${ep.name} } from '${apiFrom}';`, ...(pre ? [`import { ${pre.ep.name} } from '${preFrom}';`] : [])]),
|
|
336
|
+
`import type { ${model.name} } from '${importPath(p.file, model.file, c).replace(/\.tsx?$/, '')}';`,
|
|
337
|
+
'',
|
|
338
|
+
`export default function ${n.component}(${pc.signature}) {`,
|
|
339
|
+
...pc.lines,
|
|
340
|
+
` const [values, setValues] = useState({ ${usable.map((x) => `${key(x.f.name)}: ${x.k === 'bool' ? 'false' : "''"}`).join(', ')} });`,
|
|
341
|
+
' const [saving, setSaving] = useState(false);',
|
|
342
|
+
...(pre ? [' const [error, setError] = useState<string | null>(null);', ...pre.lines] : []),
|
|
343
|
+
'',
|
|
344
|
+
' async function submit() {',
|
|
345
|
+
...usable.filter((x) => x.required && x.k !== 'bool').map((x) => ` if (${get(x.f.name)}.trim() === '') return Alert.alert('${x.f.name} is required');`),
|
|
346
|
+
' setSaving(true);',
|
|
347
|
+
' try {',
|
|
348
|
+
` const body: ${partial ? `Partial<${model.name}>` : model.name} = {`,
|
|
349
|
+
...usable.map((x) => ` ${key(x.f.name)}: ${value(x)},`),
|
|
350
|
+
' };',
|
|
351
|
+
` await ${ep.name}(${[args, 'body'].filter(Boolean).join(', ')});`,
|
|
352
|
+
" Alert.alert('Saved');",
|
|
353
|
+
' } catch (e) {',
|
|
354
|
+
" Alert.alert('Could not save', e instanceof Error ? e.message : String(e));",
|
|
355
|
+
' } finally {',
|
|
356
|
+
' setSaving(false);',
|
|
357
|
+
' }',
|
|
358
|
+
' }',
|
|
359
|
+
'',
|
|
360
|
+
' return (',
|
|
361
|
+
' <ScrollView contentContainerStyle={styles.form}>',
|
|
362
|
+
...usable.map((x) => x.k === 'bool'
|
|
363
|
+
? ` <View style={styles.switchRow}>\n <Text>${x.f.name}</Text>\n <Switch value={${get(x.f.name)}} onValueChange={(v) => setValues({ ...values, ${key(x.f.name)}: v })} />\n </View>`
|
|
364
|
+
: ` <Text style={styles.label}>${x.f.name}${x.required ? ' *' : ''}</Text>\n <TextInput\n style={styles.input}\n value={${get(x.f.name)}}\n onChangeText={(v) => setValues({ ...values, ${key(x.f.name)}: v })}${x.k === 'number' ? '\n keyboardType="numeric"' : /email/i.test(x.f.name) ? '\n keyboardType="email-address"\n autoCapitalize="none"' : ''}${x.k === 'strings' ? '\n placeholder="a, b, c"' : ''}\n />`),
|
|
365
|
+
...(pre ? [' {error ? <Text style={styles.error}>{error}</Text> : null}'] : []),
|
|
366
|
+
` <Button title={saving ? 'Saving…' : 'Save'} onPress={submit} disabled={saving} />`,
|
|
367
|
+
' </ScrollView>',
|
|
368
|
+
' );',
|
|
369
|
+
'}',
|
|
370
|
+
'',
|
|
371
|
+
'const styles = StyleSheet.create({',
|
|
372
|
+
' form: { padding: 16, gap: 8 },',
|
|
373
|
+
...(pre ? [" error: { color: '#b3261e' },"] : []),
|
|
374
|
+
" label: { fontSize: 12, color: '#666', marginTop: 8 },",
|
|
375
|
+
" input: { borderWidth: StyleSheet.hairlineWidth, borderColor: '#999', borderRadius: 8, padding: 10 },",
|
|
376
|
+
" switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 },",
|
|
377
|
+
'});',
|
|
378
|
+
'',
|
|
379
|
+
].join('\n');
|
|
380
|
+
const files = new Map([[p.file, text]]);
|
|
381
|
+
const summary = [`Create ${p.file}: a form for ${model.name} (${usable.length} field${usable.length === 1 ? '' : 's'}) that calls ${ep.name}()`];
|
|
382
|
+
if (p.navigator) {
|
|
383
|
+
files.set(p.navigator.file, register(p.navigator, p.file, opts, map));
|
|
384
|
+
summary.push(`Update ${p.navigator.file}: add ${n.route} to RootStackParamList and the stack`);
|
|
385
|
+
}
|
|
386
|
+
summary.push(navHint(map, opts));
|
|
387
|
+
return finish(root, 'screen add', files, summary, warnings);
|
|
388
|
+
}
|