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
package/dist/skills/screen.js
CHANGED
|
@@ -19,6 +19,8 @@ import { exists, readText } from "../core/fsutil.js";
|
|
|
19
19
|
import { ToolError } from "../core/types.js";
|
|
20
20
|
import { camel, kebab, pascal, snake } from "../input/naming.js";
|
|
21
21
|
import { findDeclaration } from "./apiEdit.js";
|
|
22
|
+
import { splitTsParams } from "../adapters/reactQuery.js";
|
|
23
|
+
import { dartPagingCall, dartSearchMatch, detectPaging, PAGE_SIZE, pagingSummary, pagingWarnings, tsPagingArgs, tsPagingParams } from "./paging.js";
|
|
22
24
|
import { planModuleAdd } from "./module.js";
|
|
23
25
|
import { planRouteAdd } from "./route.js";
|
|
24
26
|
/** Dart: the text a Text widget shows for a field: a non-null String as it is, anything else interpolated (never a dead ?? on a field that turns out non-null). */
|
|
@@ -101,6 +103,14 @@ export function routeParams(path) {
|
|
|
101
103
|
function missingArgsError(ep, missing, params, name) {
|
|
102
104
|
return new ToolError('screen_args', `${ep.name}() needs ${missing.map((a) => a.name).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: codz screen add ${name} --api ${ep.name} --path /${kebab(name)}/:${missing[0].name}${params.length ? ` (the route has ${params.join(', ')})` : ''}`);
|
|
103
105
|
}
|
|
106
|
+
/** The item type of a Dart list type: List<ProductModel> → ProductModel. */
|
|
107
|
+
const itemType = (t) => /^List<(.+)>\??$/.exec(t.trim())?.[1] ?? 'dynamic';
|
|
108
|
+
/** Paging for a Dart list endpoint (Flutter screens). */
|
|
109
|
+
function dartPaging(shape, args, opts, name, returnType) {
|
|
110
|
+
if (!shape.list)
|
|
111
|
+
throw new ToolError('screen_not_list', `${name}() returns ${returnType}, not a list; --paginate and --search are for list screens.`);
|
|
112
|
+
return detectPaging(args.map((a) => ({ name: a.name, type: a.type })), { paginate: opts.paginate, search: opts.search, modelStringFields: (shape.model?.fields ?? []).filter((f) => f.type === 'String').map((f) => f.name), titleField: titleField(shape.model) });
|
|
113
|
+
}
|
|
104
114
|
// ---------------- GetX ----------------
|
|
105
115
|
async function getxScreen(root, adapter, map, opts) {
|
|
106
116
|
const ep = opts.api ? findEndpoint(map, opts.api) : undefined;
|
|
@@ -112,6 +122,8 @@ async function getxScreen(root, adapter, map, opts) {
|
|
|
112
122
|
const ctrlChange = cs.changes.find((c) => c.path.endsWith(`/controllers/${snake(opts.name)}_controller.dart`));
|
|
113
123
|
if (!viewChange?.after || !ctrlChange?.after)
|
|
114
124
|
return cs;
|
|
125
|
+
if (opts.paginate || opts.search)
|
|
126
|
+
return getxPaged(root, map, opts, ep, cs, viewChange, ctrlChange);
|
|
115
127
|
// The names api wire chose: getProducts → products / isProductsLoading / productsError / loadProducts().
|
|
116
128
|
const stripped = ep.name.replace(/^(get|fetch|load)(?=[A-Z])/, '');
|
|
117
129
|
const base = stripped !== ep.name ? camel(stripped) : ep.name;
|
|
@@ -188,6 +200,154 @@ async function getxScreen(root, adapter, map, opts) {
|
|
|
188
200
|
}
|
|
189
201
|
return cs;
|
|
190
202
|
}
|
|
203
|
+
/** GetX: the module's controller holds items, isLoading, error, query (and hasMore), and its view lists them. */
|
|
204
|
+
function getxPaged(root, map, opts, ep, cs, view, ctrl) {
|
|
205
|
+
const sig = readMethod(readText(join(root, ep.file)) ?? '', ep.name);
|
|
206
|
+
if (!sig)
|
|
207
|
+
throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
|
|
208
|
+
const args = dartArgs(sig.params);
|
|
209
|
+
const params = routeParams(opts.path);
|
|
210
|
+
const missing = args.filter((a) => a.required && !params.includes(a.name));
|
|
211
|
+
if (missing.length)
|
|
212
|
+
throw missingArgsError(ep, missing, params, opts.name);
|
|
213
|
+
const shape = responseShape(sig.returnType, map);
|
|
214
|
+
const paging = dartPaging(shape, args, opts, ep.name, sig.returnType);
|
|
215
|
+
const item = itemType(sig.returnType);
|
|
216
|
+
const repoLine = /^ {2}final (\w+) (_\w+) = Get\.find<\w+>\(\);$/m.exec(ctrl.after);
|
|
217
|
+
const cls = /class\s+(\w+)\s+extends\s+GetxController/.exec(ctrl.after)?.[1];
|
|
218
|
+
if (!repoLine || !cls)
|
|
219
|
+
throw new ToolError('not_supported', `Could not read the controller codz wrote (${ctrl.path}).`);
|
|
220
|
+
const repo = repoLine[2];
|
|
221
|
+
const searchable = Boolean(paging.search || paging.clientSearch);
|
|
222
|
+
const pg = paging.paginate;
|
|
223
|
+
const first = paging.page?.mode === 'offset' ? '0' : '1';
|
|
224
|
+
const conv = (a, v) => (/^int\??$/.test(a.type) ? `int.parse(${v})` : /^double\??$/.test(a.type) ? `double.parse(${v})` : v);
|
|
225
|
+
const routeVals = new Map(args.filter((a) => params.includes(a.name)).map((a) => [a.name, conv(a, `Get.parameters['${a.name}']!`)]));
|
|
226
|
+
const call = dartPagingCall(args, paging, routeVals, { page: pg ? 'next' : undefined, search: paging.search ? 'query.value.isEmpty ? null : query.value' : undefined }, '_pageSize');
|
|
227
|
+
const strFields = paging.searchFields.map((f) => ({ name: f, nullable: fieldOf(shape.model, f)?.nullable ?? true }));
|
|
228
|
+
const imports = ctrl.after.split('\n').filter((l) => l.startsWith('import ') && !l.includes('package:get/get.dart'));
|
|
229
|
+
ctrl.after = [
|
|
230
|
+
...(paging.search ? ["import 'dart:async';", ''] : []),
|
|
231
|
+
"import 'package:get/get.dart';",
|
|
232
|
+
'',
|
|
233
|
+
...imports,
|
|
234
|
+
'',
|
|
235
|
+
`class ${cls} extends GetxController {`,
|
|
236
|
+
repoLine[0],
|
|
237
|
+
'',
|
|
238
|
+
...(pg && paging.size ? [` static const _pageSize = ${PAGE_SIZE};`, ''] : []),
|
|
239
|
+
` // GET ${ep.path} via ${repoLine[1]}.${ep.name}`,
|
|
240
|
+
` final items = <${item}>[].obs;`,
|
|
241
|
+
' final isLoading = false.obs;',
|
|
242
|
+
' final error = RxnString();',
|
|
243
|
+
...(pg ? [' final hasMore = true.obs;'] : []),
|
|
244
|
+
...(searchable ? [" final query = ''.obs;"] : []),
|
|
245
|
+
...(pg && paging.page.mode === 'page' ? [' int _page = 0;'] : []),
|
|
246
|
+
' int _request = 0;',
|
|
247
|
+
...(paging.search ? [' Timer? _debounce;'] : []),
|
|
248
|
+
'',
|
|
249
|
+
...(paging.clientSearch
|
|
250
|
+
? [' /// The items that match the search.', ` List<${item}> get visible => query.value.isEmpty ? items : items.where((item) => ${dartSearchMatch(strFields, 'item', 'query.value', camel)}).toList();`, '']
|
|
251
|
+
: []),
|
|
252
|
+
` /// Loads ${pg ? 'the first page again (reset) or the next one' : 'the list'}. Only the latest request counts.`,
|
|
253
|
+
` Future<void> load(${pg ? '{bool reset = false}' : ''}) async {`,
|
|
254
|
+
...(pg ? [' if (!reset && (isLoading.value || !hasMore.value)) return;'] : []),
|
|
255
|
+
' final request = ++_request;',
|
|
256
|
+
' isLoading.value = true;',
|
|
257
|
+
' error.value = null;',
|
|
258
|
+
' try {',
|
|
259
|
+
...(pg ? [` final next = reset ? ${first} : ${paging.page.mode === 'offset' ? 'items.length' : '_page + 1'};`] : []),
|
|
260
|
+
` final rows = await ${repo}.${ep.name}(${call});`,
|
|
261
|
+
' if (request != _request) return;',
|
|
262
|
+
...(pg ? [' if (reset) {', ' items.assignAll(rows);', ' } else {', ' items.addAll(rows);', ' }', ...(paging.page.mode === 'page' ? [' _page = next;'] : []), ` hasMore.value = ${paging.size ? 'rows.length == _pageSize' : 'rows.isNotEmpty'};`] : [' items.assignAll(rows);']),
|
|
263
|
+
' } catch (e) {',
|
|
264
|
+
' if (request == _request) error.value = e.toString();',
|
|
265
|
+
' } finally {',
|
|
266
|
+
' if (request == _request) isLoading.value = false;',
|
|
267
|
+
' }',
|
|
268
|
+
' }',
|
|
269
|
+
...(paging.search
|
|
270
|
+
? ['', ' void search(String text) {', ' _debounce?.cancel();', ' _debounce = Timer(const Duration(milliseconds: 400), () {', ' query.value = text.trim();', ` load(${pg ? 'reset: true' : ''});`, ' });', ' }']
|
|
271
|
+
: paging.clientSearch
|
|
272
|
+
? ['', ' void search(String text) => query.value = text.trim().toLowerCase();']
|
|
273
|
+
: []),
|
|
274
|
+
'',
|
|
275
|
+
' @override',
|
|
276
|
+
' void onInit() {',
|
|
277
|
+
' super.onInit();',
|
|
278
|
+
` load(${pg ? 'reset: true' : ''});`,
|
|
279
|
+
' }',
|
|
280
|
+
...(paging.search ? ['', ' @override', ' void onClose() {', ' _debounce?.cancel();', ' super.onClose();', ' }'] : []),
|
|
281
|
+
'}',
|
|
282
|
+
'',
|
|
283
|
+
].join('\n');
|
|
284
|
+
const title = titleField(shape.model);
|
|
285
|
+
const show = (v) => (title ? dartShow(`${v}.${camel(title)}`, fieldOf(shape.model, title)) : `${v}.toString()`);
|
|
286
|
+
const reset = pg ? 'reset: true' : '';
|
|
287
|
+
const I = ' ';
|
|
288
|
+
const list = [
|
|
289
|
+
'Obx(() {',
|
|
290
|
+
`${I}final shown = controller.${paging.clientSearch ? 'visible' : 'items'};`,
|
|
291
|
+
`${I}final error = controller.error.value;`,
|
|
292
|
+
`${I}if (controller.items.isEmpty && controller.isLoading.value) return const Center(child: CircularProgressIndicator());`,
|
|
293
|
+
`${I}if (controller.items.isEmpty && error != null) {`,
|
|
294
|
+
`${I} return Center(`,
|
|
295
|
+
`${I} child: Column(`,
|
|
296
|
+
`${I} mainAxisSize: MainAxisSize.min,`,
|
|
297
|
+
`${I} children: [`,
|
|
298
|
+
`${I} Text(error, textAlign: TextAlign.center),`,
|
|
299
|
+
`${I} TextButton(onPressed: () => controller.load(${reset}), child: const Text('Retry')),`,
|
|
300
|
+
`${I} ],`,
|
|
301
|
+
`${I} ),`,
|
|
302
|
+
`${I} );`,
|
|
303
|
+
`${I}}`,
|
|
304
|
+
`${I}if (shown.isEmpty${pg ? ' && !controller.hasMore.value' : ''}) return Center(child: Text(${searchable ? "controller.query.value.isEmpty ? 'No items' : 'No matches'" : "'No items'"}));`,
|
|
305
|
+
`${I}return RefreshIndicator(`,
|
|
306
|
+
`${I} onRefresh: () => controller.load(${reset}),`,
|
|
307
|
+
`${I} child: ListView.builder(`,
|
|
308
|
+
`${I} physics: const AlwaysScrollableScrollPhysics(),`,
|
|
309
|
+
`${I} itemCount: shown.length${pg ? ' + (controller.hasMore.value || error != null ? 1 : 0)' : ''},`,
|
|
310
|
+
`${I} itemBuilder: (context, index) {`,
|
|
311
|
+
...(pg
|
|
312
|
+
? [
|
|
313
|
+
`${I} if (index == shown.length) {`,
|
|
314
|
+
`${I} if (error != null) return TextButton(onPressed: () => controller.load(), child: const Text('Retry'));`,
|
|
315
|
+
`${I} // The end of the list is on screen: ask for the next page.`,
|
|
316
|
+
`${I} if (!controller.isLoading.value) WidgetsBinding.instance.addPostFrameCallback((_) => controller.load());`,
|
|
317
|
+
`${I} return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()));`,
|
|
318
|
+
`${I} }`,
|
|
319
|
+
]
|
|
320
|
+
: []),
|
|
321
|
+
`${I} return ListTile(title: Text(${show('shown[index]')}));`,
|
|
322
|
+
`${I} },`,
|
|
323
|
+
`${I} ),`,
|
|
324
|
+
`${I});`,
|
|
325
|
+
' })',
|
|
326
|
+
];
|
|
327
|
+
const body = searchable
|
|
328
|
+
? [
|
|
329
|
+
' body: Column(',
|
|
330
|
+
' children: [',
|
|
331
|
+
' Padding(',
|
|
332
|
+
' padding: const EdgeInsets.all(12),',
|
|
333
|
+
" child: TextField(onChanged: controller.search, decoration: const InputDecoration(hintText: 'Search', prefixIcon: Icon(Icons.search), border: OutlineInputBorder())),",
|
|
334
|
+
' ),',
|
|
335
|
+
` Expanded(child: ${list.map((l, i) => (i === 0 ? l : ` ${l}`)).join('\n')}),`,
|
|
336
|
+
' ],',
|
|
337
|
+
' ),',
|
|
338
|
+
].join('\n')
|
|
339
|
+
: ` body: ${list.join('\n')},`;
|
|
340
|
+
const replaced = view.after.replace(/ {6}body: const Center\(child: Text\('\w+ is working'\)\),/, body);
|
|
341
|
+
if (replaced === view.after)
|
|
342
|
+
throw new ToolError('not_supported', `Could not fill the view codz wrote (${view.path}).`);
|
|
343
|
+
view.after = replaced;
|
|
344
|
+
cs.summary = cs.summary.filter((l) => !/^In \w+View: Obx|^Update .*controller\.dart: load|^Show /.test(l));
|
|
345
|
+
cs.summary.push(`${ctrl.path}: items, isLoading, error${searchable ? ', query' : ''}${pg ? ', hasMore' : ''} and load()`, `${view.path} lists them and ${pagingSummary(paging)}`);
|
|
346
|
+
cs.warnings.push(...pagingWarnings(paging));
|
|
347
|
+
if (params.length)
|
|
348
|
+
cs.warnings.push(`The controller reads ${params.join(', ')} from Get.parameters; open the page with Get.toNamed(Routes.X, parameters: {...}).`);
|
|
349
|
+
return cs;
|
|
350
|
+
}
|
|
191
351
|
// ---------------- Flutter (riverpod, get_it) ----------------
|
|
192
352
|
async function flutterScreen(root, adapter, map, opts) {
|
|
193
353
|
const route = await planRouteAdd(root, adapter, { name: opts.name, path: opts.path, initial: opts.initial });
|
|
@@ -233,6 +393,146 @@ async function flutterScreen(root, adapter, map, opts) {
|
|
|
233
393
|
const stateBase = riverpod ? 'ConsumerStatefulWidget' : 'StatefulWidget';
|
|
234
394
|
const stateCls = riverpod ? `ConsumerState<${cls}>` : `State<${cls}>`;
|
|
235
395
|
const titleText = page.after.match(/AppBar\(title: const Text\('([^']*)'\)\)/)?.[1] ?? pascal(opts.name);
|
|
396
|
+
if (opts.paginate || opts.search) {
|
|
397
|
+
const paging = dartPaging(shape, args, opts, ep.name, sig.returnType);
|
|
398
|
+
const routeVals = new Map(args.filter((a) => params.includes(a.name)).map((a) => [a.name, /^int\??$/.test(a.type) ? `int.parse(widget.${a.name})` : /^double\??$/.test(a.type) ? `double.parse(widget.${a.name})` : `widget.${a.name}`]));
|
|
399
|
+
const item = itemType(sig.returnType);
|
|
400
|
+
const searchable = Boolean(paging.search || paging.clientSearch);
|
|
401
|
+
const first = paging.page?.mode === 'offset' ? '0' : '1';
|
|
402
|
+
const call = dartPagingCall(args, paging, routeVals, { page: paging.paginate ? 'next' : undefined, search: paging.search ? '_query.isEmpty ? null : _query' : undefined }, '_pageSize');
|
|
403
|
+
const show = (v) => (title ? dartShow(`${v}.${camel(title)}`, fieldOf(shape.model, title)) : `${v}.toString()`);
|
|
404
|
+
const strFields = paging.searchFields.map((n) => ({ name: n, nullable: fieldOf(shape.model, n)?.nullable ?? true }));
|
|
405
|
+
let text = [
|
|
406
|
+
...(paging.search ? ["import 'dart:async';", ''] : []),
|
|
407
|
+
"import 'package:flutter/material.dart';",
|
|
408
|
+
'',
|
|
409
|
+
`class ${cls} extends ${stateBase} {`,
|
|
410
|
+
` ${ctor}`,
|
|
411
|
+
...(fields ? ['', fields.trimEnd()] : []),
|
|
412
|
+
'',
|
|
413
|
+
' @override',
|
|
414
|
+
` ${stateCls.replace(/<.*/, '')}<${cls}> createState() => _${cls}State();`,
|
|
415
|
+
'}',
|
|
416
|
+
'',
|
|
417
|
+
`class _${cls}State extends ${stateCls} {`,
|
|
418
|
+
...(paging.paginate && paging.size ? [` static const _pageSize = ${PAGE_SIZE};`, ''] : []),
|
|
419
|
+
` final _items = <${item}>[];`,
|
|
420
|
+
...(paging.search ? [' Timer? _debounce;'] : []),
|
|
421
|
+
...(searchable ? [" String _query = '';"] : []),
|
|
422
|
+
...(paging.paginate && paging.page.mode === 'page' ? [' int _page = 0;'] : []),
|
|
423
|
+
...(paging.paginate ? [' bool _hasMore = true;'] : []),
|
|
424
|
+
' bool _loading = false;',
|
|
425
|
+
' Object? _error;',
|
|
426
|
+
' int _request = 0;',
|
|
427
|
+
'',
|
|
428
|
+
' @override',
|
|
429
|
+
' void initState() {',
|
|
430
|
+
' super.initState();',
|
|
431
|
+
` _load(${paging.paginate ? 'reset: true' : ''});`,
|
|
432
|
+
' }',
|
|
433
|
+
...(paging.search ? ['', ' @override', ' void dispose() {', ' _debounce?.cancel();', ' super.dispose();', ' }'] : []),
|
|
434
|
+
'',
|
|
435
|
+
` /// Loads ${paging.paginate ? 'the first page again (reset) or the next one' : 'the list'}.`,
|
|
436
|
+
` Future<void> _load(${paging.paginate ? '{bool reset = false}' : ''}) async {`,
|
|
437
|
+
...(paging.paginate ? [' if (!reset && (_loading || !_hasMore)) return;'] : []),
|
|
438
|
+
' // Only the latest request counts: a new search or refresh replaces one still loading.',
|
|
439
|
+
' final request = ++_request;',
|
|
440
|
+
' setState(() {',
|
|
441
|
+
' _loading = true;',
|
|
442
|
+
' _error = null;',
|
|
443
|
+
' });',
|
|
444
|
+
' try {',
|
|
445
|
+
...(paging.paginate ? [` final next = reset ? ${first} : ${paging.page.mode === 'offset' ? '_items.length' : '_page + 1'};`] : []),
|
|
446
|
+
` final rows = await ${repoExpr}.${ep.name}(${call});`,
|
|
447
|
+
' if (!mounted || request != _request) return;',
|
|
448
|
+
' setState(() {',
|
|
449
|
+
...(paging.paginate ? [' if (reset) _items.clear();'] : [' _items.clear();']),
|
|
450
|
+
' _items.addAll(rows);',
|
|
451
|
+
...(paging.paginate ? [...(paging.page.mode === 'page' ? [' _page = next;'] : []), ` _hasMore = ${paging.size ? 'rows.length == _pageSize' : 'rows.isNotEmpty'};`] : []),
|
|
452
|
+
' });',
|
|
453
|
+
' } catch (e) {',
|
|
454
|
+
' if (mounted && request == _request) setState(() => _error = e);',
|
|
455
|
+
' } finally {',
|
|
456
|
+
' if (mounted && request == _request) setState(() => _loading = false);',
|
|
457
|
+
' }',
|
|
458
|
+
' }',
|
|
459
|
+
...(paging.search
|
|
460
|
+
? ['', ' void _onSearch(String text) {', ' _debounce?.cancel();', ' _debounce = Timer(const Duration(milliseconds: 400), () {', ' _query = text.trim();', ` _load(${paging.paginate ? 'reset: true' : ''});`, ' });', ' }']
|
|
461
|
+
: paging.clientSearch
|
|
462
|
+
? ['', ' void _onSearch(String text) => setState(() => _query = text.trim().toLowerCase());']
|
|
463
|
+
: []),
|
|
464
|
+
'',
|
|
465
|
+
' @override',
|
|
466
|
+
' Widget build(BuildContext context) {',
|
|
467
|
+
' return Scaffold(',
|
|
468
|
+
` appBar: AppBar(title: const Text('${titleText}')),`,
|
|
469
|
+
...(searchable
|
|
470
|
+
? [
|
|
471
|
+
' body: Column(',
|
|
472
|
+
' children: [',
|
|
473
|
+
' Padding(',
|
|
474
|
+
' padding: const EdgeInsets.all(12),',
|
|
475
|
+
" child: TextField(onChanged: _onSearch, decoration: const InputDecoration(hintText: 'Search', prefixIcon: Icon(Icons.search), border: OutlineInputBorder())),",
|
|
476
|
+
' ),',
|
|
477
|
+
' Expanded(child: _list()),',
|
|
478
|
+
' ],',
|
|
479
|
+
' ),',
|
|
480
|
+
]
|
|
481
|
+
: [' body: _list(),']),
|
|
482
|
+
' );',
|
|
483
|
+
' }',
|
|
484
|
+
'',
|
|
485
|
+
' Widget _list() {',
|
|
486
|
+
` final shown = ${paging.clientSearch ? `_query.isEmpty ? _items : _items.where((item) => ${dartSearchMatch(strFields, 'item', '_query', camel)}).toList()` : '_items'};`,
|
|
487
|
+
' if (_items.isEmpty && _loading) return const Center(child: CircularProgressIndicator());',
|
|
488
|
+
' if (_items.isEmpty && _error != null) {',
|
|
489
|
+
' return Center(',
|
|
490
|
+
' child: Column(',
|
|
491
|
+
' mainAxisSize: MainAxisSize.min,',
|
|
492
|
+
' children: [',
|
|
493
|
+
" Text('$_error', textAlign: TextAlign.center),",
|
|
494
|
+
` TextButton(onPressed: () => _load(${paging.paginate ? 'reset: true' : ''}), child: const Text('Retry')),`,
|
|
495
|
+
' ],',
|
|
496
|
+
' ),',
|
|
497
|
+
' );',
|
|
498
|
+
' }',
|
|
499
|
+
` if (shown.isEmpty${paging.paginate ? ' && !_hasMore' : ''}) return Center(child: Text(${searchable ? "_query.isEmpty ? 'No items' : 'No matches'" : "'No items'"}));`,
|
|
500
|
+
' return RefreshIndicator(',
|
|
501
|
+
` onRefresh: () => _load(${paging.paginate ? 'reset: true' : ''}),`,
|
|
502
|
+
' child: ListView.builder(',
|
|
503
|
+
' physics: const AlwaysScrollableScrollPhysics(),',
|
|
504
|
+
` itemCount: shown.length${paging.paginate ? ' + (_hasMore || _error != null ? 1 : 0)' : ''},`,
|
|
505
|
+
' itemBuilder: (context, index) {',
|
|
506
|
+
...(paging.paginate
|
|
507
|
+
? [
|
|
508
|
+
' if (index == shown.length) {',
|
|
509
|
+
" if (_error != null) return TextButton(onPressed: () => _load(), child: const Text('Retry'));",
|
|
510
|
+
' // The end of the list is on screen: ask for the next page.',
|
|
511
|
+
' if (!_loading) WidgetsBinding.instance.addPostFrameCallback((_) => _load());',
|
|
512
|
+
' return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()));',
|
|
513
|
+
' }',
|
|
514
|
+
]
|
|
515
|
+
: []),
|
|
516
|
+
` return ListTile(title: Text(${show('shown[index]')}));`,
|
|
517
|
+
' },',
|
|
518
|
+
' ),',
|
|
519
|
+
' );',
|
|
520
|
+
' }',
|
|
521
|
+
'}',
|
|
522
|
+
'',
|
|
523
|
+
].join('\n');
|
|
524
|
+
if (riverpod)
|
|
525
|
+
text = addDartImport(text, 'package:flutter_riverpod/flutter_riverpod.dart');
|
|
526
|
+
text = addDartImport(text, importUri(page.path, ep.file, c));
|
|
527
|
+
if (getIt && c.diFile)
|
|
528
|
+
text = addDartImport(text, importUri(page.path, c.diFile, c));
|
|
529
|
+
if (shape.model && shape.model.file !== ep.file)
|
|
530
|
+
text = addDartImport(text, importUri(page.path, shape.model.file, c));
|
|
531
|
+
page.after = text;
|
|
532
|
+
route.summary.push(`${page.path} lists ${ep.name}() and ${pagingSummary(paging)}`);
|
|
533
|
+
route.warnings.push(...pagingWarnings(paging));
|
|
534
|
+
return route;
|
|
535
|
+
}
|
|
236
536
|
const I = ' ';
|
|
237
537
|
const dataView = shape.list
|
|
238
538
|
? [
|
|
@@ -344,6 +644,8 @@ function blocScreen(root, route, page, cls, ep, sig, args, params, map, opts) {
|
|
|
344
644
|
const repoExpr = getIt ? `${c.diInstance || 'getIt'}<${sig.repoClass}>()` : `context.read<${sig.repoClass}>()`;
|
|
345
645
|
if (!getIt)
|
|
346
646
|
route.warnings.push(`${page.path} reads ${sig.repoClass} with context.read, so provide it above this page: RepositoryProvider(create: (_) => ${sig.repoClass}(...), child: ...).`);
|
|
647
|
+
if (opts.paginate || opts.search)
|
|
648
|
+
return blocPagedScreen(root, route, page, cls, ep, sig, args, params, map, opts, { cubit, state, cubitFile, repoExpr, base });
|
|
347
649
|
let cubitText = [
|
|
348
650
|
"import 'package:flutter_bloc/flutter_bloc.dart';",
|
|
349
651
|
'',
|
|
@@ -469,6 +771,171 @@ function blocScreen(root, route, page, cls, ep, sig, args, params, map, opts) {
|
|
|
469
771
|
const summary = [...route.summary, `Create ${cubitFile}: ${cubit} with loading, loaded and failure states, calling ${sig.repoClass}.${ep.name}()`, `${page.path} provides ${cubit} and shows loading, error with retry, and the data`];
|
|
470
772
|
return buildChangeSet(root, 'screen add', { files, summary, warnings: route.warnings, generated: [] });
|
|
471
773
|
}
|
|
774
|
+
/** Bloc: a Cubit with one state (items, loading, error, query, hasMore) for a paged or searchable list, and its page. */
|
|
775
|
+
function blocPagedScreen(root, route, page, cls, ep, sig, args, params, map, opts, n) {
|
|
776
|
+
const c = map.conventions;
|
|
777
|
+
const T = sig.returnType;
|
|
778
|
+
const shape = responseShape(T, map);
|
|
779
|
+
const paging = dartPaging(shape, args, opts, ep.name, T);
|
|
780
|
+
const item = itemType(T);
|
|
781
|
+
const { cubit, state } = n;
|
|
782
|
+
const searchable = Boolean(paging.search || paging.clientSearch);
|
|
783
|
+
const pg = paging.paginate;
|
|
784
|
+
const first = paging.page?.mode === 'offset' ? '0' : '1';
|
|
785
|
+
const conv = (a, v) => (/^int\??$/.test(a.type) ? `int.parse(${v})` : /^double\??$/.test(a.type) ? `double.parse(${v})` : v);
|
|
786
|
+
const routeVals = new Map(args.filter((a) => params.includes(a.name)).map((a) => [a.name, conv(a, a.name)]));
|
|
787
|
+
const call = dartPagingCall(args, paging, routeVals, { page: pg ? 'next' : undefined, search: paging.search ? 'state.query.isEmpty ? null : state.query' : undefined }, '_pageSize');
|
|
788
|
+
const title = titleField(shape.model);
|
|
789
|
+
const show = (v) => (title ? dartShow(`${v}.${camel(title)}`, fieldOf(shape.model, title)) : `${v}.toString()`);
|
|
790
|
+
const strFields = paging.searchFields.map((f) => ({ name: f, nullable: fieldOf(shape.model, f)?.nullable ?? true }));
|
|
791
|
+
const stateFields = [
|
|
792
|
+
{ decl: `final List<${item}> items;`, param: 'this.items = const []', copy: `List<${item}>? items`, set: 'items: items ?? this.items' },
|
|
793
|
+
{ decl: 'final bool loading;', param: 'this.loading = false', copy: 'bool? loading', set: 'loading: loading ?? this.loading' },
|
|
794
|
+
...(pg ? [{ decl: 'final bool hasMore;', param: 'this.hasMore = true', copy: 'bool? hasMore', set: 'hasMore: hasMore ?? this.hasMore' }] : []),
|
|
795
|
+
{ decl: 'final String? error;', param: 'this.error', copy: 'String? error, bool clearError = false', set: 'error: clearError ? null : (error ?? this.error)' },
|
|
796
|
+
...(searchable ? [{ decl: 'final String query;', param: "this.query = ''", copy: 'String? query', set: 'query: query ?? this.query' }] : []),
|
|
797
|
+
];
|
|
798
|
+
let cubitText = [
|
|
799
|
+
...(paging.search ? ["import 'dart:async';", ''] : []),
|
|
800
|
+
"import 'package:flutter_bloc/flutter_bloc.dart';",
|
|
801
|
+
'',
|
|
802
|
+
`class ${state} {`,
|
|
803
|
+
` const ${state}({${stateFields.map((f) => f.param).join(', ')}});`,
|
|
804
|
+
'',
|
|
805
|
+
...stateFields.map((f) => ` ${f.decl}`),
|
|
806
|
+
'',
|
|
807
|
+
` ${state} copyWith({${stateFields.map((f) => f.copy).join(', ')}}) => ${state}(`,
|
|
808
|
+
...stateFields.map((f) => ` ${f.set},`),
|
|
809
|
+
' );',
|
|
810
|
+
'}',
|
|
811
|
+
'',
|
|
812
|
+
`class ${cubit} extends Cubit<${state}> {`,
|
|
813
|
+
` ${cubit}(this._api${params.length ? `, {${params.map((p) => `required this.${p}`).join(', ')}}` : ''}) : super(const ${state}());`,
|
|
814
|
+
'',
|
|
815
|
+
...(pg && paging.size ? [` static const _pageSize = ${PAGE_SIZE};`, ''] : []),
|
|
816
|
+
` final ${sig.repoClass} _api;`,
|
|
817
|
+
...params.map((p) => ` final String ${p};`),
|
|
818
|
+
...(pg && paging.page.mode === 'page' ? [' int _page = 0;'] : []),
|
|
819
|
+
' int _request = 0;',
|
|
820
|
+
...(paging.search ? [' Timer? _debounce;'] : []),
|
|
821
|
+
'',
|
|
822
|
+
` /// Loads ${pg ? 'the first page again (reset) or the next one' : 'the list'}. Only the latest request counts.`,
|
|
823
|
+
` Future<void> load(${pg ? '{bool reset = false}' : ''}) async {`,
|
|
824
|
+
...(pg ? [' if (!reset && (state.loading || !state.hasMore)) return;'] : []),
|
|
825
|
+
' final request = ++_request;',
|
|
826
|
+
' emit(state.copyWith(loading: true, clearError: true));',
|
|
827
|
+
' try {',
|
|
828
|
+
...(pg ? [` final next = reset ? ${first} : ${paging.page.mode === 'offset' ? 'state.items.length' : '_page + 1'};`] : []),
|
|
829
|
+
` final rows = await _api.${ep.name}(${call});`,
|
|
830
|
+
' if (request != _request || isClosed) return;',
|
|
831
|
+
...(pg && paging.page.mode === 'page' ? [' _page = next;'] : []),
|
|
832
|
+
pg
|
|
833
|
+
? ` emit(state.copyWith(items: reset ? rows : [...state.items, ...rows], hasMore: ${paging.size ? 'rows.length == _pageSize' : 'rows.isNotEmpty'}, loading: false));`
|
|
834
|
+
: ' emit(state.copyWith(items: rows, loading: false));',
|
|
835
|
+
' } catch (e) {',
|
|
836
|
+
' if (request == _request && !isClosed) emit(state.copyWith(loading: false, error: e.toString()));',
|
|
837
|
+
' }',
|
|
838
|
+
' }',
|
|
839
|
+
...(paging.search
|
|
840
|
+
? ['', ' void search(String text) {', ' _debounce?.cancel();', ' _debounce = Timer(const Duration(milliseconds: 400), () {', ' emit(state.copyWith(query: text.trim()));', ` load(${pg ? 'reset: true' : ''});`, ' });', ' }', '', ' @override', ' Future<void> close() {', ' _debounce?.cancel();', ' return super.close();', ' }']
|
|
841
|
+
: paging.clientSearch
|
|
842
|
+
? ['', ' void search(String text) => emit(state.copyWith(query: text.trim().toLowerCase()));']
|
|
843
|
+
: []),
|
|
844
|
+
'}',
|
|
845
|
+
'',
|
|
846
|
+
].join('\n');
|
|
847
|
+
cubitText = addDartImport(cubitText, importUri(n.cubitFile, ep.file, c));
|
|
848
|
+
if (shape.model && shape.model.file !== ep.file)
|
|
849
|
+
cubitText = addDartImport(cubitText, importUri(n.cubitFile, shape.model.file, c));
|
|
850
|
+
const reset = pg ? 'reset: true' : '';
|
|
851
|
+
const titleText = page.after.match(/AppBar\(title: const Text\('([^']*)'\)\)/)?.[1] ?? n.base;
|
|
852
|
+
const ctor = params.length ? `const ${cls}({super.key, ${params.map((p) => `required this.${p}`).join(', ')}});` : `const ${cls}({super.key});`;
|
|
853
|
+
const getIt = c.di === 'get_it';
|
|
854
|
+
let pageText = [
|
|
855
|
+
"import 'package:flutter/material.dart';",
|
|
856
|
+
"import 'package:flutter_bloc/flutter_bloc.dart';",
|
|
857
|
+
'',
|
|
858
|
+
`class ${cls} extends StatelessWidget {`,
|
|
859
|
+
` ${ctor}`,
|
|
860
|
+
...(params.length ? ['', ...params.map((p) => ` final String ${p};`)] : []),
|
|
861
|
+
'',
|
|
862
|
+
' @override',
|
|
863
|
+
' Widget build(BuildContext context) {',
|
|
864
|
+
' return BlocProvider(',
|
|
865
|
+
` create: (context) => ${cubit}(${n.repoExpr}${params.length ? `, ${params.map((p) => `${p}: ${p}`).join(', ')}` : ''})..load(${reset}),`,
|
|
866
|
+
' child: Scaffold(',
|
|
867
|
+
` appBar: AppBar(title: const Text('${titleText}')),`,
|
|
868
|
+
` body: BlocBuilder<${cubit}, ${state}>(`,
|
|
869
|
+
...(searchable
|
|
870
|
+
? [
|
|
871
|
+
' builder: (context, state) => Column(',
|
|
872
|
+
' children: [',
|
|
873
|
+
' Padding(',
|
|
874
|
+
' padding: const EdgeInsets.all(12),',
|
|
875
|
+
` child: TextField(onChanged: context.read<${cubit}>().search, decoration: const InputDecoration(hintText: 'Search', prefixIcon: Icon(Icons.search), border: OutlineInputBorder())),`,
|
|
876
|
+
' ),',
|
|
877
|
+
' Expanded(child: _list(context, state)),',
|
|
878
|
+
' ],',
|
|
879
|
+
' ),',
|
|
880
|
+
]
|
|
881
|
+
: [' builder: _list,']),
|
|
882
|
+
' ),',
|
|
883
|
+
' ),',
|
|
884
|
+
' );',
|
|
885
|
+
' }',
|
|
886
|
+
'',
|
|
887
|
+
` Widget _list(BuildContext context, ${state} state) {`,
|
|
888
|
+
` final shown = ${paging.clientSearch ? `state.query.isEmpty ? state.items : state.items.where((item) => ${dartSearchMatch(strFields, 'item', 'state.query', camel)}).toList()` : 'state.items'};`,
|
|
889
|
+
' final error = state.error;',
|
|
890
|
+
' if (state.items.isEmpty && state.loading) return const Center(child: CircularProgressIndicator());',
|
|
891
|
+
' if (state.items.isEmpty && error != null) {',
|
|
892
|
+
' return Center(',
|
|
893
|
+
' child: Column(',
|
|
894
|
+
' mainAxisSize: MainAxisSize.min,',
|
|
895
|
+
' children: [',
|
|
896
|
+
' Text(error, textAlign: TextAlign.center),',
|
|
897
|
+
` TextButton(onPressed: () => context.read<${cubit}>().load(${reset}), child: const Text('Retry')),`,
|
|
898
|
+
' ],',
|
|
899
|
+
' ),',
|
|
900
|
+
' );',
|
|
901
|
+
' }',
|
|
902
|
+
` if (shown.isEmpty${pg ? ' && !state.hasMore' : ''}) return Center(child: Text(${searchable ? "state.query.isEmpty ? 'No items' : 'No matches'" : "'No items'"}));`,
|
|
903
|
+
' return RefreshIndicator(',
|
|
904
|
+
` onRefresh: () => context.read<${cubit}>().load(${reset}),`,
|
|
905
|
+
' child: ListView.builder(',
|
|
906
|
+
' physics: const AlwaysScrollableScrollPhysics(),',
|
|
907
|
+
` itemCount: shown.length${pg ? ' + (state.hasMore || error != null ? 1 : 0)' : ''},`,
|
|
908
|
+
' itemBuilder: (context, index) {',
|
|
909
|
+
...(pg
|
|
910
|
+
? [
|
|
911
|
+
' if (index == shown.length) {',
|
|
912
|
+
` if (error != null) return TextButton(onPressed: () => context.read<${cubit}>().load(), child: const Text('Retry'));`,
|
|
913
|
+
' // The end of the list is on screen: ask for the next page.',
|
|
914
|
+
` if (!state.loading) WidgetsBinding.instance.addPostFrameCallback((_) => context.read<${cubit}>().load());`,
|
|
915
|
+
' return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()));',
|
|
916
|
+
' }',
|
|
917
|
+
]
|
|
918
|
+
: []),
|
|
919
|
+
` return ListTile(title: Text(${show('shown[index]')}));`,
|
|
920
|
+
' },',
|
|
921
|
+
' ),',
|
|
922
|
+
' );',
|
|
923
|
+
' }',
|
|
924
|
+
'}',
|
|
925
|
+
'',
|
|
926
|
+
].join('\n');
|
|
927
|
+
pageText = addDartImport(pageText, importUri(page.path, n.cubitFile, c));
|
|
928
|
+
if (getIt && c.diFile)
|
|
929
|
+
pageText = addDartImport(pageText, importUri(page.path, c.diFile, c));
|
|
930
|
+
pageText = addDartImport(pageText, importUri(page.path, ep.file, c));
|
|
931
|
+
page.after = pageText;
|
|
932
|
+
const files = new Map(route.changes.filter((ch) => ch.after !== null).map((ch) => [ch.path, ch.after]));
|
|
933
|
+
if (exists(join(root, n.cubitFile)))
|
|
934
|
+
throw new ToolError('file_exists', `${n.cubitFile} already exists.`, 'Pick another screen name.');
|
|
935
|
+
files.set(n.cubitFile, cubitText);
|
|
936
|
+
const summary = [...route.summary, `Create ${n.cubitFile}: ${cubit} with items, loading, error${searchable ? ', query' : ''}${pg ? ' and hasMore' : ''}, calling ${sig.repoClass}.${ep.name}()`, `${page.path} lists it and ${pagingSummary(paging)}`];
|
|
937
|
+
return buildChangeSet(root, 'screen add', { files, summary, warnings: [...route.warnings, ...pagingWarnings(paging)], generated: [] });
|
|
938
|
+
}
|
|
472
939
|
// ---------------- Next.js ----------------
|
|
473
940
|
async function nextScreen(root, adapter, map, opts) {
|
|
474
941
|
const route = await planRouteAdd(root, adapter, { name: opts.name, path: opts.path, initial: opts.initial });
|
|
@@ -523,28 +990,91 @@ async function nextScreen(root, adapter, map, opts) {
|
|
|
523
990
|
? [' <dl>', ...shape.model.fields.flatMap((f) => [` <dt>${f.name}</dt>`, ` <dd>{String(${dataVar}.${f.name} ?? '')}</dd>`]), ' </dl>']
|
|
524
991
|
: [` <pre>{JSON.stringify(${dataVar}, null, 2)}</pre>`];
|
|
525
992
|
const sigParams = params.length ? `{ params }: { params: Promise<{ ${params.map((p) => `${p}: string`).join('; ')} }> }` : '';
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
'
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
'
|
|
540
|
-
|
|
541
|
-
'
|
|
542
|
-
''
|
|
543
|
-
|
|
993
|
+
const pkgText = readText(join(root, 'package.json')) ?? '';
|
|
994
|
+
const major = Number(/"next"\s*:\s*"[^\d]*(\d+)/.exec(pkgText)?.[1] ?? 15);
|
|
995
|
+
let pagingInfo = null;
|
|
996
|
+
if (opts.paginate || opts.search) {
|
|
997
|
+
if (!shape.list)
|
|
998
|
+
throw new ToolError('screen_not_list', `${ep.name}() returns one ${shape.raw || 'value'}, not a list; --paginate and --search are for list screens.`);
|
|
999
|
+
const ps = splitTsParams(decl.params);
|
|
1000
|
+
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 });
|
|
1001
|
+
const routeArgs = new Map(ps.filter((x) => params.includes(x.name)).map((x) => [x.name, /^number$/.test(x.type) ? `Number(${x.name})` : x.name]));
|
|
1002
|
+
const searchable = Boolean(paging.search || paging.clientSearch);
|
|
1003
|
+
const offset = paging.page?.mode === 'offset';
|
|
1004
|
+
const urlKeys = [...(paging.paginate ? [offset ? 'offset' : 'page'] : []), ...(searchable ? ['q'] : [])];
|
|
1005
|
+
const sp = urlKeys.map((k) => `${k}?: string`).join('; ');
|
|
1006
|
+
const props = [...(params.length ? [`params: ${major >= 15 ? 'Promise<' : ''}{ ${params.map((x) => `${x}: string`).join('; ')} }${major >= 15 ? '>' : ''}`] : []), `searchParams: ${major >= 15 ? 'Promise<' : ''}{ ${sp} }${major >= 15 ? '>' : ''}`];
|
|
1007
|
+
const aw = major >= 15 ? 'await ' : '';
|
|
1008
|
+
const call = tsPagingArgs(ps, paging, routeArgs, { page: paging.paginate ? (offset ? 'offset' : 'page') : undefined, search: paging.search ? 'q || undefined' : undefined });
|
|
1009
|
+
const access = (v, f) => (/^[A-Za-z_$][\w$]*$/.test(f) ? `${v}.${f}` : `${v}['${f}']`);
|
|
1010
|
+
const more = paging.size ? 'items.length === PAGE_SIZE' : 'items.length > 0';
|
|
1011
|
+
const qs = (pageExpr) => `\`?\${new URLSearchParams({ ${searchable ? '...(q ? { q } : {}), ' : ''}${offset ? 'offset' : 'page'}: String(${pageExpr}) })}\``;
|
|
1012
|
+
page.after = [
|
|
1013
|
+
...(paging.paginate ? [`import Link from ${q}next/link${q}${semi}`] : []),
|
|
1014
|
+
`import { ${ep.name} } from ${q}${from}${q}${semi}`,
|
|
1015
|
+
'',
|
|
1016
|
+
'// Rendered on each request: the data, the page and the search come from the request.',
|
|
1017
|
+
`export const dynamic = ${q}force-dynamic${q}${semi}`,
|
|
1018
|
+
...(paging.paginate && paging.size ? [`const PAGE_SIZE = ${20}${semi}`] : []),
|
|
1019
|
+
'',
|
|
1020
|
+
`export default async function ${comp}({ ${[...(params.length ? ['params'] : []), 'searchParams'].join(', ')} }: { ${props.join('; ')} }) {`,
|
|
1021
|
+
...(params.length ? [` const { ${params.join(', ')} } = ${aw}params${semi}`] : []),
|
|
1022
|
+
` const { ${[...(paging.paginate ? [`${offset ? 'offset' : 'page'}: at`] : []), ...(searchable ? [`q = ${q}${q}`] : [])].join(', ')} } = ${aw}searchParams${semi}`,
|
|
1023
|
+
...(paging.paginate ? [offset ? ` const offset = Math.max(0, Number(at) || 0)${semi}` : ` const page = Math.max(1, Number(at) || 1)${semi}`] : []),
|
|
1024
|
+
` const items = await ${ep.name}(${call})${semi}`,
|
|
1025
|
+
...(paging.clientSearch
|
|
1026
|
+
? [` const needle = q.trim().toLowerCase()${semi}`, ` const shown = needle ? items.filter((item) => [${paging.searchFields.map((f) => access('item', f)).join(', ')}].some((v) => String(v ?? ${q}${q}).toLowerCase().includes(needle))) : items${semi}`]
|
|
1027
|
+
: [` const shown = items${semi}`]),
|
|
1028
|
+
' return (',
|
|
1029
|
+
' <main>',
|
|
1030
|
+
` <h1>${title}</h1>`,
|
|
1031
|
+
...(searchable ? [' <form role="search">', ' <input type="search" name="q" defaultValue={q} placeholder="Search" aria-label="Search" />', ' <button type="submit">Search</button>', ' </form>'] : []),
|
|
1032
|
+
' {shown.length === 0 ? (',
|
|
1033
|
+
` <p>${searchable ? `{q ? 'No matches.' : 'Nothing here yet.'}` : 'Nothing here yet.'}</p>`,
|
|
1034
|
+
' ) : (',
|
|
1035
|
+
' <ul>',
|
|
1036
|
+
` {shown.map((item${idf ? '' : ', index'}) => (`,
|
|
1037
|
+
` <li key={${idf ? `String(item.${idf})` : 'index'}}>{${tf ? `String(item.${tf})` : 'JSON.stringify(item)'}}</li>`,
|
|
1038
|
+
' ))}',
|
|
1039
|
+
' </ul>',
|
|
1040
|
+
' )}',
|
|
1041
|
+
...(paging.paginate
|
|
1042
|
+
? [
|
|
1043
|
+
' <nav aria-label="Pages">',
|
|
1044
|
+
offset ? ` {offset > 0 ? <Link href={${qs('0')}}>First page</Link> : null}` : ` {page > 1 ? <Link href={${qs('page - 1')}}>Previous</Link> : null}`,
|
|
1045
|
+
` {${more} ? <Link href={${qs(offset ? 'offset + items.length' : 'page + 1')}}>Next</Link> : null}`,
|
|
1046
|
+
' </nav>',
|
|
1047
|
+
]
|
|
1048
|
+
: []),
|
|
1049
|
+
' </main>',
|
|
1050
|
+
` )${semi}`,
|
|
1051
|
+
'}',
|
|
1052
|
+
'',
|
|
1053
|
+
].join('\n');
|
|
1054
|
+
pagingInfo = { summary: `${page.path} lists ${ep.name}() on the server and ${pagingSummary(paging, 'links')}`, warnings: pagingWarnings(paging) };
|
|
1055
|
+
}
|
|
1056
|
+
else
|
|
1057
|
+
page.after = [
|
|
1058
|
+
`import { ${ep.name} } from ${q}${from}${q}${semi}`,
|
|
1059
|
+
'',
|
|
1060
|
+
// Without route parameters Next.js would render the page once at build time,
|
|
1061
|
+
// calling the API then (and failing the build when it is not reachable).
|
|
1062
|
+
...(params.length ? [] : ['// Rendered on each request: the data comes from the API.', `export const dynamic = ${q}force-dynamic${q}${semi}`, '']),
|
|
1063
|
+
`export default async function ${comp}(${sigParams}) {`,
|
|
1064
|
+
...(params.length ? [` const { ${params.join(', ')} } = await params${semi}`] : []),
|
|
1065
|
+
` const ${dataVar} = await ${ep.name}(${callArgs})${semi}`,
|
|
1066
|
+
' return (',
|
|
1067
|
+
' <main>',
|
|
1068
|
+
` <h1>${title}</h1>`,
|
|
1069
|
+
...render,
|
|
1070
|
+
' </main>',
|
|
1071
|
+
` )${semi}`,
|
|
1072
|
+
'}',
|
|
1073
|
+
'',
|
|
1074
|
+
].join('\n');
|
|
544
1075
|
// Next 14 passes params directly, not as a promise.
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
if (nextMajor < 15 && params.length)
|
|
1076
|
+
const nextMajor = major;
|
|
1077
|
+
if (!pagingInfo && nextMajor < 15 && params.length)
|
|
548
1078
|
page.after = page.after.replace(`params: Promise<{`, 'params: {').replace(/ }> }\) \{/, ' } }) {').replace(`= await params${semi}`, `= params${semi}`);
|
|
549
1079
|
const folder = posix.dirname(page.path);
|
|
550
1080
|
const ext = page.path.endsWith('.tsx') ? 'tsx' : 'jsx';
|
|
@@ -571,8 +1101,8 @@ async function nextScreen(root, adapter, map, opts) {
|
|
|
571
1101
|
for (const [f, t] of extra)
|
|
572
1102
|
if (!exists(join(root, f)))
|
|
573
1103
|
files.set(f, t);
|
|
574
|
-
const summary = [...route.summary, `${page.path} awaits ${ep.name}() on the server and renders the result`, `Create ${folder}/loading.${ext} and ${folder}/error.${ext} (loading state, and an error with retry)`];
|
|
575
|
-
return buildChangeSet(root, 'screen add', { files, summary, warnings: route.warnings, generated: [] });
|
|
1104
|
+
const summary = [...route.summary, pagingInfo?.summary ?? `${page.path} awaits ${ep.name}() on the server and renders the result`, `Create ${folder}/loading.${ext} and ${folder}/error.${ext} (loading state, and an error with retry)`];
|
|
1105
|
+
return buildChangeSet(root, 'screen add', { files, summary, warnings: [...route.warnings, ...(pagingInfo?.warnings ?? [])], generated: [] });
|
|
576
1106
|
}
|
|
577
1107
|
export async function planScreenAdd(root, adapter, opts) {
|
|
578
1108
|
const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
|