codz-cli 0.1.2 → 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.
@@ -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',
@@ -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 ? (',