codz-cli 0.1.3 → 0.1.8
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 +140 -17
- package/bin/codz.js +0 -0
- package/dist/adapters/common.js +3 -1
- package/dist/adapters/flutterScan.js +1 -1
- package/dist/cli.js +162 -10
- package/dist/core/cache.js +8 -2
- package/dist/core/commands.js +25 -6
- package/dist/core/modelGroup.js +180 -0
- package/dist/core/validateConfig.js +6 -0
- package/dist/skills/androidUi.js +167 -1
- package/dist/skills/androidViews.js +181 -1
- package/dist/skills/apiAdd.js +3 -0
- package/dist/skills/apiImport.js +3 -1
- package/dist/skills/apiSync.js +3 -1
- package/dist/skills/assets.js +289 -0
- package/dist/skills/env.js +509 -0
- package/dist/skills/feature.js +1 -1
- package/dist/skills/field.js +419 -0
- package/dist/skills/iosUi.js +154 -0
- package/dist/skills/locale.js +13 -4
- package/dist/skills/localeExtract.js +540 -0
- package/dist/skills/mockServer.js +512 -0
- package/dist/skills/model.js +100 -0
- package/dist/skills/paging.js +185 -0
- package/dist/skills/reactNativeUi.js +72 -0
- package/dist/skills/reactUi.js +55 -0
- package/dist/skills/screen.js +553 -23
- package/docs/COMMANDS.md +109 -8
- package/package.json +2 -3
- package/dist/skills/mcp.js +0 -175
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// screen add --paginate --search: which of an endpoint's parameters ask for a
|
|
2
|
+
// page, a page size and a search term, in any of the four languages. The UI
|
|
3
|
+
// for each framework is written next to its normal list screen; this only
|
|
4
|
+
// reads the endpoint and decides what the screen can do.
|
|
5
|
+
import { ToolError } from "../core/types.js";
|
|
6
|
+
export const PAGE_SIZE = 20;
|
|
7
|
+
const PAGE = /^(page|pageNumber|pageNo|pageIndex|p)$/i;
|
|
8
|
+
const OFFSET = /^(offset|skip|start|from)$/i;
|
|
9
|
+
const SIZE = /^(limit|pageSize|perPage|per_page|page_size|size|count|take|first)$/i;
|
|
10
|
+
const SEARCH = /^(q|query|search|searchTerm|search_term|term|keyword|keywords|text|filter)$/i;
|
|
11
|
+
/** TS parameters, with the keys of an options object ({ page?: number; search?: string }) flattened in. */
|
|
12
|
+
export function tsPagingParams(ps) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const p of ps) {
|
|
15
|
+
const obj = /^\{([^]*)\}\s*(\|\s*undefined)?$/.exec(p.type.trim());
|
|
16
|
+
if (obj) {
|
|
17
|
+
for (const m of obj[1].matchAll(/(\w+)\??\s*:\s*([^;,]+)[;,]?/g))
|
|
18
|
+
out.push({ name: m[1], type: m[2].trim(), inObject: p.name });
|
|
19
|
+
}
|
|
20
|
+
else
|
|
21
|
+
out.push({ name: p.name, type: p.type });
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
export function detectPaging(params, opts) {
|
|
26
|
+
const numeric = (t) => /^(number|int|Int|Long|Double|num|Int\?|Long\?|int\?|number \| undefined)/.test(t.replace(/\s/g, ' '));
|
|
27
|
+
const find = (re, num) => params.find((p) => re.test(p.name) && (!num || numeric(p.type)));
|
|
28
|
+
const page = find(PAGE, true);
|
|
29
|
+
const offset = page ? undefined : find(OFFSET, true);
|
|
30
|
+
const size = find(SIZE, true);
|
|
31
|
+
const search = find(SEARCH);
|
|
32
|
+
const paging = {
|
|
33
|
+
paginate: Boolean(opts.paginate),
|
|
34
|
+
page: page ? { ...page, mode: 'page' } : offset ? { ...offset, mode: 'offset' } : undefined,
|
|
35
|
+
search: opts.search ? search : undefined,
|
|
36
|
+
clientSearch: Boolean(opts.search && !search),
|
|
37
|
+
searchFields: [],
|
|
38
|
+
};
|
|
39
|
+
if (opts.paginate && paging.page)
|
|
40
|
+
paging.size = size;
|
|
41
|
+
else
|
|
42
|
+
paging.size = undefined;
|
|
43
|
+
if (!opts.paginate)
|
|
44
|
+
paging.page = undefined;
|
|
45
|
+
if (opts.paginate && !paging.page) {
|
|
46
|
+
throw new ToolError('no_paging', 'The endpoint has no page or offset parameter, so the screen cannot ask for the next page.', `Its parameters: ${params.map((p) => p.name).join(', ') || 'none'}. Add a page query parameter to the endpoint (codz api add again with ?page=1 in the URL, or api sync from a spec that has it), or leave out --paginate.`);
|
|
47
|
+
}
|
|
48
|
+
if (paging.clientSearch) {
|
|
49
|
+
const fields = [...new Set([opts.titleField, ...opts.modelStringFields].filter((f) => !!f && opts.modelStringFields.includes(f)))];
|
|
50
|
+
if (!fields.length)
|
|
51
|
+
throw new ToolError('no_search', 'The endpoint has no search parameter, and its items have no text fields to search in.', 'Add a q or search query parameter to the endpoint, or leave out --search.');
|
|
52
|
+
paging.searchFields = fields.slice(0, 4);
|
|
53
|
+
}
|
|
54
|
+
return paging;
|
|
55
|
+
}
|
|
56
|
+
/** What the screen does, for the summary line. */
|
|
57
|
+
export function pagingSummary(p, more = 'scroll') {
|
|
58
|
+
const parts = [];
|
|
59
|
+
if (p.paginate && p.page)
|
|
60
|
+
parts.push(`loads ${PAGE_SIZE} at a time (${p.page.name}${p.size ? ` + ${p.size.name}` : ''}), ${more === 'scroll' ? 'more as you scroll' : more === 'button' ? 'a Load more button for the next' : 'Previous and Next links'}`);
|
|
61
|
+
if (p.search)
|
|
62
|
+
parts.push(`searches on the server (${p.search.name})`);
|
|
63
|
+
else if (p.clientSearch)
|
|
64
|
+
parts.push(`searches the loaded items (${p.searchFields.join(', ')})`);
|
|
65
|
+
return parts.join('; ');
|
|
66
|
+
}
|
|
67
|
+
export function pagingWarnings(p) {
|
|
68
|
+
const w = [];
|
|
69
|
+
if (p.clientSearch)
|
|
70
|
+
w.push(`The endpoint has no search parameter, so search filters the items already loaded${p.paginate ? ' (not the pages not loaded yet)' : ''}. Add a q or search query parameter to the endpoint to search on the server.`);
|
|
71
|
+
if (p.paginate && !p.size)
|
|
72
|
+
w.push(`The endpoint has no page-size parameter, so the screen asks for pages until one comes back empty.`);
|
|
73
|
+
return w;
|
|
74
|
+
}
|
|
75
|
+
/** TypeScript call arguments: positional ones in order, keys of the options object together. */
|
|
76
|
+
export function tsPagingArgs(ps, p, routeArgs, v) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const param of ps) {
|
|
79
|
+
if (routeArgs.has(param.name)) {
|
|
80
|
+
out.push(routeArgs.get(param.name));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const keys = [];
|
|
84
|
+
const own = (x) => x && (x.inObject ? x.inObject === param.name : x.name === param.name);
|
|
85
|
+
const val = (x, expr) => (x && expr && own(x) ? (x.inObject ? keys.push(x.name === expr ? x.name : `${x.name}: ${expr}`) : out.push(expr), true) : false);
|
|
86
|
+
const handled = [val(p.page, v.page), val(p.size, v.page && p.size ? 'PAGE_SIZE' : undefined), val(p.search, v.search)].some(Boolean);
|
|
87
|
+
if (keys.length)
|
|
88
|
+
out.push(`{ ${keys.join(', ')} }`);
|
|
89
|
+
else if (!handled) {
|
|
90
|
+
if (!param.optional)
|
|
91
|
+
return out.join(', '); // the caller already reported missing required arguments
|
|
92
|
+
out.push('undefined');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
while (out[out.length - 1] === 'undefined')
|
|
96
|
+
out.pop();
|
|
97
|
+
return out.join(', ');
|
|
98
|
+
}
|
|
99
|
+
/** Named arguments (Dart `name: v`, Kotlin `name = v`, Swift `name: v`) for page, size and search. */
|
|
100
|
+
export function namedPagingArgs(p, v, sep, sizeExpr = String(PAGE_SIZE)) {
|
|
101
|
+
const out = [];
|
|
102
|
+
if (p.page && v.page)
|
|
103
|
+
out.push(`${p.page.name}${sep}${v.page}`);
|
|
104
|
+
if (p.size && v.page)
|
|
105
|
+
out.push(`${p.size.name}${sep}${sizeExpr}`);
|
|
106
|
+
if (p.search && v.search)
|
|
107
|
+
out.push(`${p.search.name}${sep}${v.search}`);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The state and loading for a paged or searchable list: plain React state, or
|
|
112
|
+
* TanStack Query v5 (useInfiniteQuery / useQuery) when the project uses it.
|
|
113
|
+
* `call(v)` gives the endpoint call for a page and a search expression.
|
|
114
|
+
*/
|
|
115
|
+
export function tsPagedLogic(p, o) {
|
|
116
|
+
const first = p.page?.mode === 'offset' ? '0' : '1';
|
|
117
|
+
const top = p.page && p.size ? [`const PAGE_SIZE = ${PAGE_SIZE};`, ''] : [];
|
|
118
|
+
const more = (rows) => (p.size ? `${rows}.length === PAGE_SIZE` : `${rows}.length > 0`);
|
|
119
|
+
const searchExpr = p.search ? `search || undefined` : undefined;
|
|
120
|
+
const body = [];
|
|
121
|
+
const react = new Set();
|
|
122
|
+
const query = [];
|
|
123
|
+
if (p.search || p.clientSearch) {
|
|
124
|
+
react.add('useState');
|
|
125
|
+
body.push(` const [query, setQuery] = useState('');`);
|
|
126
|
+
}
|
|
127
|
+
if (p.search) {
|
|
128
|
+
react.add('useEffect');
|
|
129
|
+
body.push(` const [search, setSearch] = useState('');`, '', ' // Search after a short pause in typing, not on every key.', ' useEffect(() => {', ' const t = setTimeout(() => setSearch(query.trim()), 400);', ' return () => clearTimeout(t);', ' }, [query]);', '');
|
|
130
|
+
}
|
|
131
|
+
const keyParts = [`'${o.key}'`, ...o.deps, ...(p.search ? ['search'] : [])].join(', ');
|
|
132
|
+
if (o.reactQuery && p.paginate) {
|
|
133
|
+
query.push('useInfiniteQuery');
|
|
134
|
+
body.push(` const { data, error, isLoading, ${o.refresh ? 'isFetching, ' : ''}isFetchingNextPage, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({`, ` queryKey: [${keyParts}, 'pages'],`, ` queryFn: ({ pageParam }) => ${o.call({ page: 'pageParam', search: searchExpr })},`, ` initialPageParam: ${first},`, ` getNextPageParam: (last, all) => (${more('last')} ? ${p.page.mode === 'offset' ? 'all.flat().length' : 'all.length + 1'} : undefined),`, ' });', ` const items: ${o.T}[] = data?.pages.flat() ?? [];`, ' const loading = isLoading || isFetchingNextPage;', ...(o.hasMore ? [' const hasMore = Boolean(hasNextPage);'] : []), ' const loadMore = () => {', ' if (hasNextPage && !isFetchingNextPage) fetchNextPage();', ' };', ' const retry = () => refetch();', ...(o.refresh ? [' const refresh = () => refetch();', ' const refreshing = isFetching && !isLoading && !isFetchingNextPage;'] : []));
|
|
135
|
+
}
|
|
136
|
+
else if (o.reactQuery) {
|
|
137
|
+
query.push('useQuery');
|
|
138
|
+
body.push(` const { data, error, isLoading: loading, ${o.refresh ? 'isFetching, ' : ''}refetch } = useQuery({`, ` queryKey: [${keyParts}],`, ` queryFn: () => ${o.call({ search: searchExpr })},`, ' });', ` const items: ${o.T}[] = data ?? [];`, ' const retry = () => refetch();', ...(o.refresh ? [' const refresh = () => refetch();', ' const refreshing = isFetching && !loading;'] : []));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
react.add('useState');
|
|
142
|
+
react.add('useEffect');
|
|
143
|
+
react.add('useRef');
|
|
144
|
+
body.push(` const [items, setItems] = useState<${o.T}[]>([]);`, ...(p.paginate ? [` const [hasMore, setHasMore] = useState(true);`] : []), ...(p.paginate && p.page.mode === 'page' ? [` const [page, setPage] = useState(${first});`] : []), ' const [loading, setLoading] = useState(true);', ...(o.refresh ? [' const [refreshing, setRefreshing] = useState(false);'] : []), ' const [error, setError] = useState<unknown>(null);', ' // Only the latest request counts: a new search or refresh replaces one still loading.', ' const request = useRef(0);', '', ` /** Loads ${p.paginate ? 'the first page again (reset) or the next one' : 'the list'}. */`, ` async function load(${p.paginate ? 'reset: boolean' : ''}) {`, ' const id = ++request.current;', ' setLoading(true);', ' setError(null);', ' try {', ...(p.paginate ? [` const next = reset ? ${first} : ${p.page.mode === 'offset' ? 'items.length' : 'page + 1'};`] : []), ` const rows = await ${o.call({ page: p.paginate ? 'next' : undefined, search: searchExpr })};`, ' if (id !== request.current) return;', ...(p.paginate ? [' setItems((prev) => (reset ? rows : [...prev, ...rows]));', ` setHasMore(${more('rows')});`, ...(p.page.mode === 'page' ? [' setPage(next);'] : [])] : [' setItems(rows);']), ' } catch (e) {', ' if (id === request.current) setError(e);', ' } finally {', ' if (id === request.current) {', ' setLoading(false);', ...(o.refresh ? [' setRefreshing(false);'] : []), ' }', ' }', ' }', '', ' useEffect(() => {', ` load(${p.paginate ? 'true' : ''});`, ' // eslint-disable-next-line react-hooks/exhaustive-deps', ` }, [${[...o.deps, ...(p.search ? ['search'] : [])].join(', ')}]);`, '', ...(p.paginate ? [' const loadMore = () => {', ' if (hasMore && !loading && !error) load(false);', ' };'] : []), ` const retry = () => load(${p.paginate ? 'items.length === 0' : ''});`, ...(o.refresh ? [' const refresh = () => {', ' setRefreshing(true);', ` load(${p.paginate ? 'true' : ''});`, ' };'] : []));
|
|
145
|
+
}
|
|
146
|
+
if (p.clientSearch) {
|
|
147
|
+
body.push('', ' const needle = query.trim().toLowerCase();', ` const shown = needle ? items.filter((item) => [${p.searchFields.map((f) => o.access('item', f)).join(', ')}].some((v) => String(v ?? '').toLowerCase().includes(needle))) : items;`);
|
|
148
|
+
}
|
|
149
|
+
else
|
|
150
|
+
body.push(' const shown = items;');
|
|
151
|
+
return { imports: { react: [...react], query }, top, body };
|
|
152
|
+
}
|
|
153
|
+
// ---------- Dart ----------
|
|
154
|
+
/** Dart call arguments: positional in order (null for unused optional ones), named as name: value. */
|
|
155
|
+
export function dartPagingCall(args, p, route, v, sizeExpr) {
|
|
156
|
+
const value = (name) => {
|
|
157
|
+
if (route.has(name))
|
|
158
|
+
return route.get(name);
|
|
159
|
+
if (p.page && v.page && p.page.name === name)
|
|
160
|
+
return v.page;
|
|
161
|
+
if (p.size && v.page && p.size.name === name)
|
|
162
|
+
return sizeExpr;
|
|
163
|
+
if (p.search && v.search && p.search.name === name)
|
|
164
|
+
return v.search;
|
|
165
|
+
return undefined;
|
|
166
|
+
};
|
|
167
|
+
const positional = [];
|
|
168
|
+
const named = [];
|
|
169
|
+
for (const a of args) {
|
|
170
|
+
const val = value(a.name);
|
|
171
|
+
if (a.named) {
|
|
172
|
+
if (val !== undefined)
|
|
173
|
+
named.push(`${a.name}: ${val}`);
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
positional.push(val ?? 'null');
|
|
177
|
+
}
|
|
178
|
+
while (positional.length && positional[positional.length - 1] === 'null' && !args.filter((a) => !a.named)[positional.length - 1]?.required)
|
|
179
|
+
positional.pop();
|
|
180
|
+
return [...positional, ...named].join(', ');
|
|
181
|
+
}
|
|
182
|
+
/** Dart: does any of these String fields of `item` contain `needle` (already lower-case)? */
|
|
183
|
+
export function dartSearchMatch(fields, item, needle, prop) {
|
|
184
|
+
return `[${fields.map((f) => `${item}.${prop(f.name)}${f.nullable ? " ?? ''" : ''}`).join(', ')}].any((v) => v.toLowerCase().contains(${needle}))`;
|
|
185
|
+
}
|
|
@@ -19,6 +19,8 @@ import { camel, kebab, pascal, words } from "../input/naming.js";
|
|
|
19
19
|
import { findDeclaration } from "./apiEdit.js";
|
|
20
20
|
import { responseShape, titleField } from "./screen.js";
|
|
21
21
|
import { reactPrefill } from "./prefill.js";
|
|
22
|
+
import { detectPaging, pagingSummary, pagingWarnings, tsPagedLogic, tsPagingArgs, tsPagingParams } from "./paging.js";
|
|
23
|
+
import { queryMajor } from "./reactUi.js";
|
|
22
24
|
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
23
25
|
function routeParamsOf(path) {
|
|
24
26
|
return [...(path ?? '').matchAll(/[:{[]([\w-]+)[\]}]?/g)].map((m) => camel(m[1]));
|
|
@@ -183,6 +185,76 @@ export function planRnScreen(root, map, opts) {
|
|
|
183
185
|
const fromApi = importPath(p.file, barrel ?? ep.file, c).replace(/\.tsx?$/, '');
|
|
184
186
|
const T = shape.raw || 'unknown';
|
|
185
187
|
const access = (v, f) => (IDENT.test(f) ? `${v}.${f}` : `${v}['${f}']`);
|
|
188
|
+
if (opts.paginate || opts.search) {
|
|
189
|
+
if (!shape.list)
|
|
190
|
+
throw new ToolError('screen_not_list', `${ep.name}() returns one ${T}, not a list; --paginate and --search are for list screens.`);
|
|
191
|
+
const ps = splitTsParams(decl.params);
|
|
192
|
+
const paging = detectPaging(tsPagingParams(ps), { paginate: opts.paginate, search: opts.search, modelStringFields: (shape.model?.fields ?? []).filter((f) => f.type === 'string').map((f) => f.name), titleField: tf });
|
|
193
|
+
const routeArgs = new Map(ps.filter((x) => params.includes(x.name)).map((x) => [x.name, /^number$/.test(x.type) ? `Number(${x.name})` : x.name]));
|
|
194
|
+
const item = shape.model?.name ?? T.replace(/\[\]$/, '');
|
|
195
|
+
const logic = tsPagedLogic(paging, { T: item, call: (v) => `${ep.name}(${tsPagingArgs(ps, paging, routeArgs, v)})`, key: kebab(ep.name), deps: params, reactQuery: c.dataFetching === 'react-query' && queryMajor(root) >= 5, refresh: true, hasMore: false, access });
|
|
196
|
+
const searchable = paging.search || paging.clientSearch;
|
|
197
|
+
const rn = ['ActivityIndicator', 'Button', 'FlatList', 'StyleSheet', 'Text', ...(searchable ? ['TextInput'] : []), 'View'];
|
|
198
|
+
const paged = [
|
|
199
|
+
...(logic.imports.react.length ? [`import { ${logic.imports.react.sort().join(', ')} } from 'react';`] : []),
|
|
200
|
+
`import { ${rn.join(', ')} } from 'react-native';`,
|
|
201
|
+
...(logic.imports.query.length ? [`import { ${logic.imports.query.join(', ')} } from '@tanstack/react-query';`] : []),
|
|
202
|
+
...pc.imports,
|
|
203
|
+
`import { ${ep.name} } from '${fromApi}';`,
|
|
204
|
+
...(shape.model ? [`import type { ${shape.model.name} } from '${importPath(p.file, shape.model.file, c).replace(/\.tsx?$/, '')}';`] : []),
|
|
205
|
+
'',
|
|
206
|
+
...logic.top,
|
|
207
|
+
`export default function ${n.component}(${pc.signature}) {`,
|
|
208
|
+
...pc.lines,
|
|
209
|
+
...logic.body,
|
|
210
|
+
'',
|
|
211
|
+
' return (',
|
|
212
|
+
' <FlatList',
|
|
213
|
+
' data={shown}',
|
|
214
|
+
` keyExtractor={(item, index) => ${idf ? `String(${access('item', idf)})` : 'String(index)'}}`,
|
|
215
|
+
' refreshing={refreshing}',
|
|
216
|
+
' onRefresh={refresh}',
|
|
217
|
+
...(paging.paginate ? [' onEndReached={loadMore}', ' onEndReachedThreshold={0.5}'] : []),
|
|
218
|
+
...(searchable ? [' keyboardShouldPersistTaps="handled"', ' ListHeaderComponent={<TextInput style={styles.search} placeholder="Search" value={query} onChangeText={setQuery} autoCorrect={false} clearButtonMode="while-editing" />}'] : []),
|
|
219
|
+
' ListEmptyComponent={',
|
|
220
|
+
' loading ? (',
|
|
221
|
+
' <ActivityIndicator style={styles.footer} />',
|
|
222
|
+
' ) : error ? (',
|
|
223
|
+
' <View style={styles.center}>',
|
|
224
|
+
' <Text style={styles.error}>{error instanceof Error ? error.message : String(error)}</Text>',
|
|
225
|
+
' <Button title="Retry" onPress={retry} />',
|
|
226
|
+
' </View>',
|
|
227
|
+
' ) : (',
|
|
228
|
+
` <Text style={styles.empty}>${searchable ? `{query.trim() ? 'No matches.' : 'Nothing here yet.'}` : 'Nothing here yet.'}</Text>`,
|
|
229
|
+
' )',
|
|
230
|
+
' }',
|
|
231
|
+
...(paging.paginate
|
|
232
|
+
? [' ListFooterComponent={', ' items.length > 0 && loading ? <ActivityIndicator style={styles.footer} /> : items.length > 0 && error ? <Button title="Retry" onPress={retry} /> : null', ' }']
|
|
233
|
+
: []),
|
|
234
|
+
` renderItem={({ item }) => <Text style={styles.row}>{${tf ? `String(${access('item', tf)})` : 'JSON.stringify(item)'}}</Text>}`,
|
|
235
|
+
' />',
|
|
236
|
+
' );',
|
|
237
|
+
'}',
|
|
238
|
+
'',
|
|
239
|
+
'const styles = StyleSheet.create({',
|
|
240
|
+
" center: { alignItems: 'center', justifyContent: 'center', padding: 16 },",
|
|
241
|
+
" error: { marginBottom: 12, textAlign: 'center' },",
|
|
242
|
+
" empty: { padding: 16, textAlign: 'center' },",
|
|
243
|
+
" footer: { padding: 16 },",
|
|
244
|
+
...(searchable ? [" search: { margin: 12, padding: 10, borderWidth: StyleSheet.hairlineWidth, borderColor: '#999', borderRadius: 8 },"] : []),
|
|
245
|
+
" row: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ccc' },",
|
|
246
|
+
'});',
|
|
247
|
+
'',
|
|
248
|
+
].join('\n');
|
|
249
|
+
const files = new Map([[p.file, paged]]);
|
|
250
|
+
const summary = [`Create ${p.file}: ${n.component} lists ${ep.name}() and ${pagingSummary(paging)}`];
|
|
251
|
+
if (p.navigator) {
|
|
252
|
+
files.set(p.navigator.file, register(p.navigator, p.file, opts, map));
|
|
253
|
+
summary.push(`Update ${p.navigator.file}: add ${n.route} to RootStackParamList and the stack`);
|
|
254
|
+
}
|
|
255
|
+
summary.push(navHint(map, opts));
|
|
256
|
+
return finish(root, 'screen add', files, summary, [...warnings, ...pagingWarnings(paging)]);
|
|
257
|
+
}
|
|
186
258
|
const dataView = shape.list
|
|
187
259
|
? [
|
|
188
260
|
' <FlatList',
|
package/dist/skills/reactUi.js
CHANGED
|
@@ -19,6 +19,7 @@ import { camel, kebab, pascal, words } from "../input/naming.js";
|
|
|
19
19
|
import { findDeclaration } from "./apiEdit.js";
|
|
20
20
|
import { responseShape, titleField } from "./screen.js";
|
|
21
21
|
import { reactPrefill } from "./prefill.js";
|
|
22
|
+
import { detectPaging, pagingSummary, pagingWarnings, tsPagedLogic, tsPagingArgs, tsPagingParams } from "./paging.js";
|
|
22
23
|
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
23
24
|
function routeParamsOf(path) {
|
|
24
25
|
return [...(path ?? '').matchAll(/[:{]([\w-]+)\}?/g)].map((m) => camel(m[1]));
|
|
@@ -119,6 +120,11 @@ export function planReactRoute(root, map, opts) {
|
|
|
119
120
|
const page = [...pc.imports, ...(pc.imports.length ? [''] : []), `export default function ${n.component}() {`, ...pc.lines, ' return (', ' <main>', ` <h1>${n.title}${params.length ? ` {${params[0]}}` : ''}</h1>`, ' </main>', ' );', '}', ''].join('\n');
|
|
120
121
|
return finish(root, 'route add', p, page, map, opts, [`Create ${p.file}: ${n.component}`], []);
|
|
121
122
|
}
|
|
123
|
+
/** The installed TanStack Query major version (5 when it cannot be read). */
|
|
124
|
+
export function queryMajor(root) {
|
|
125
|
+
const pkg = readText(join(root, 'package.json')) ?? '';
|
|
126
|
+
return Number(/"@tanstack\/react-query"\s*:\s*"[^\d]*(\d+)/.exec(pkg)?.[1] ?? 5);
|
|
127
|
+
}
|
|
122
128
|
function findEp(map, name) {
|
|
123
129
|
const ep = map.endpoints.find((e) => e.name === name) ?? map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
124
130
|
if (!ep)
|
|
@@ -174,6 +180,55 @@ export function planReactScreen(root, map, opts) {
|
|
|
174
180
|
const c = map.conventions;
|
|
175
181
|
const T = shape.raw || 'unknown';
|
|
176
182
|
const access = (v, f) => (IDENT.test(f) ? `${v}.${f}` : `${v}['${f}']`);
|
|
183
|
+
if (opts.paginate || opts.search) {
|
|
184
|
+
if (!shape.list)
|
|
185
|
+
throw new ToolError('screen_not_list', `${ep.name}() returns one ${T}, not a list; --paginate and --search are for list screens.`);
|
|
186
|
+
const ps = splitTsParams(decl.params);
|
|
187
|
+
const paging = detectPaging(tsPagingParams(ps), { paginate: opts.paginate, search: opts.search, modelStringFields: (shape.model?.fields ?? []).filter((f) => f.type === 'string').map((f) => f.name), titleField: tf });
|
|
188
|
+
const routeArgs = new Map(ps.filter((x) => params.includes(x.name)).map((x) => [x.name, /^number$/.test(x.type) ? `Number(${x.name})` : x.name]));
|
|
189
|
+
const item = shape.model?.name ?? T.replace(/\[\]$/, '');
|
|
190
|
+
const logic = tsPagedLogic(paging, { T: item, call: (v) => `${ep.name}(${tsPagingArgs(ps, paging, routeArgs, v)})`, key: kebab(ep.name), deps: params, reactQuery: c.dataFetching === 'react-query' && queryMajor(root) >= 5, refresh: false, hasMore: true, access });
|
|
191
|
+
const searchable = paging.search || paging.clientSearch;
|
|
192
|
+
const pagedPage = [
|
|
193
|
+
...(logic.imports.react.length ? [`import { ${logic.imports.react.sort().join(', ')} } from 'react';`] : []),
|
|
194
|
+
...(logic.imports.query.length ? [`import { ${logic.imports.query.join(', ')} } from '@tanstack/react-query';`] : []),
|
|
195
|
+
...pc.imports,
|
|
196
|
+
`import { ${ep.name} } from '${apiImport(root, map, p.file, ep)}';`,
|
|
197
|
+
...(shape.model ? [`import type { ${shape.model.name} } from '${importPath(p.file, shape.model.file, c).replace(/\.tsx?$/, '')}';`] : []),
|
|
198
|
+
'',
|
|
199
|
+
...logic.top,
|
|
200
|
+
`export default function ${n.component}() {`,
|
|
201
|
+
...pc.lines,
|
|
202
|
+
...logic.body,
|
|
203
|
+
'',
|
|
204
|
+
' return (',
|
|
205
|
+
' <main>',
|
|
206
|
+
` <h1>${n.title}</h1>`,
|
|
207
|
+
...(searchable ? [' <input type="search" placeholder="Search" aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} />'] : []),
|
|
208
|
+
' {error ? (',
|
|
209
|
+
' <div role="alert">',
|
|
210
|
+
' <p>{error instanceof Error ? error.message : String(error)}</p>',
|
|
211
|
+
' <button type="button" onClick={() => retry()}>Retry</button>',
|
|
212
|
+
' </div>',
|
|
213
|
+
' ) : null}',
|
|
214
|
+
' {shown.length === 0 ? (',
|
|
215
|
+
` loading || error ? null : <p>${searchable ? `{query.trim() ? 'No matches.' : 'Nothing here yet.'}` : 'Nothing here yet.'}</p>`,
|
|
216
|
+
' ) : (',
|
|
217
|
+
' <ul>',
|
|
218
|
+
` {shown.map((${idf ? 'item' : 'item, index'}) => (`,
|
|
219
|
+
` <li key={${idf ? `String(${access('item', idf)})` : 'index'}}>{${tf ? `String(${access('item', tf)})` : 'JSON.stringify(item)'}}</li>`,
|
|
220
|
+
' ))}',
|
|
221
|
+
' </ul>',
|
|
222
|
+
' )}',
|
|
223
|
+
' {loading ? <p>Loading…</p> : null}',
|
|
224
|
+
...(paging.paginate ? [' {hasMore && !loading && !error ? (', ' <button type="button" onClick={loadMore}>Load more</button>', ' ) : null}'] : []),
|
|
225
|
+
' </main>',
|
|
226
|
+
' );',
|
|
227
|
+
'}',
|
|
228
|
+
'',
|
|
229
|
+
].join('\n');
|
|
230
|
+
return finish(root, 'screen add', p, pagedPage, map, opts, [`Create ${p.file}: ${n.component} lists ${ep.name}() and ${pagingSummary(paging, 'button')}`], pagingWarnings(paging));
|
|
231
|
+
}
|
|
177
232
|
const view = shape.list
|
|
178
233
|
? [
|
|
179
234
|
' {data.length === 0 ? (',
|