codz-cli 0.0.0-stage → 0.1.2
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 +525 -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 +638 -0
- package/dist/skills/createDemo.js +1026 -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,418 @@
|
|
|
1
|
+
// feature add <name>: a whole feature in one previewed change.
|
|
2
|
+
//
|
|
3
|
+
// codz feature add orders --spec api.yaml endpoints from the spec whose path or tag is "orders"
|
|
4
|
+
// codz feature add orders --spec api.yaml --tag Orders
|
|
5
|
+
// codz feature add orders --api getOrders --api getOrder --api createOrder (endpoints already in the project)
|
|
6
|
+
//
|
|
7
|
+
// It runs the existing generators in order, in a private copy of the project
|
|
8
|
+
// (src/core/workspace.ts), so each step builds on the last:
|
|
9
|
+
//
|
|
10
|
+
// 1. api import the endpoints, models and DI registration
|
|
11
|
+
// 2. screen add a list screen (GET returning a list), a detail screen (GET with an id),
|
|
12
|
+
// a create form (POST with a body) and an edit form (PUT/PATCH with a body)
|
|
13
|
+
// 3. test add tests for the new endpoints
|
|
14
|
+
//
|
|
15
|
+
// The difference between the copy and the project is returned as ONE change
|
|
16
|
+
// set: one preview, one apply, one undo. Nothing in the project changes until
|
|
17
|
+
// it is applied.
|
|
18
|
+
import { loadMap } from "../core/cache.js";
|
|
19
|
+
import { loadConfig } from "../core/config.js";
|
|
20
|
+
import { readText, walk } from "../core/fsutil.js";
|
|
21
|
+
import { openWorkspace } from "../core/workspace.js";
|
|
22
|
+
import { ToolError } from "../core/types.js";
|
|
23
|
+
import { readApiFile } from "../input/apiSpec.js";
|
|
24
|
+
import { camel, kebab, pascal, singular, snake, words } from "../input/naming.js";
|
|
25
|
+
import { pathMatches, planApiImport } from "./apiImport.js";
|
|
26
|
+
import { planSetup } from "./setup.js";
|
|
27
|
+
import { linkViewsList } from "./androidViews.js";
|
|
28
|
+
import { linkIosList } from "./iosUi.js";
|
|
29
|
+
import { planScreenAdd } from "./screen.js";
|
|
30
|
+
import { planTestAdd } from "./testgen.js";
|
|
31
|
+
import { idField, responseShape } from "./screen.js";
|
|
32
|
+
import { linkAndroidList, linkFlutterList, linkNextList, linkReactList, linkRnList } from "./featureLinks.js";
|
|
33
|
+
import { editComposeDetail, editFlutterDetail, editIosDetail, editNextDetail, editReactDetail, editRnDetail, editViewsDetail } from "./featureEdit.js";
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
/** Endpoints in a spec that belong to the feature: an explicit --tag/--path filter, else paths or tags that name it. */
|
|
36
|
+
export function featureOperations(specText, opts) {
|
|
37
|
+
const doc = readApiFile(specText, { baseUrl: opts.baseUrl });
|
|
38
|
+
const wantTags = new Set((opts.tags ?? []).map((t) => t.toLowerCase()));
|
|
39
|
+
const explicit = wantTags.size > 0 || (opts.paths?.length ?? 0) > 0;
|
|
40
|
+
const nameForms = new Set([opts.name, kebab(opts.name), words(opts.name).join('_'), camel(opts.name), singular(kebab(opts.name))].map((s) => s.toLowerCase()));
|
|
41
|
+
const ops = doc.operations.filter((o) => {
|
|
42
|
+
if (explicit)
|
|
43
|
+
return (!wantTags.size || o.tags.some((t) => wantTags.has(t.toLowerCase()))) && (!opts.paths?.length || opts.paths.some((g) => pathMatches(g, o.spec.path)));
|
|
44
|
+
const segs = o.spec.path.split('/').filter((s) => s && !s.startsWith('{')).map((s) => s.toLowerCase());
|
|
45
|
+
return segs.some((s) => nameForms.has(s)) || o.tags.some((t) => nameForms.has(t.toLowerCase()) || nameForms.has(kebab(t)));
|
|
46
|
+
});
|
|
47
|
+
return { doc, ops };
|
|
48
|
+
}
|
|
49
|
+
const isListType = (t) => !!t && /(\[\]|^List<.*>|^Array<.*>)\??$/.test(t.replace(/^Promise<(.*)>$/, '$1').replace(/^Future<(.*)>$/, '$1').replace(/^Flow<(.*)>$/, '$1').trim());
|
|
50
|
+
/** Picks at most one endpoint per screen kind and names the screens and routes. */
|
|
51
|
+
export function planScreens(feature, cands, forms) {
|
|
52
|
+
const plural = pascal(feature);
|
|
53
|
+
const one = pascal(singular(kebab(feature)));
|
|
54
|
+
const base = `/${kebab(feature)}`;
|
|
55
|
+
const routeOf = (c, suffix = '') => {
|
|
56
|
+
// Parameters that come before the resource (a parent id) and the resource id, in path order.
|
|
57
|
+
const parts = c.params.map((p) => `:${p}`);
|
|
58
|
+
return [base, ...parts].join('/') + suffix;
|
|
59
|
+
};
|
|
60
|
+
const picks = [];
|
|
61
|
+
// Order matters for routers that match in order (go_router, React Navigation linking): /orders/new before /orders/:id.
|
|
62
|
+
const list = cands.find((c) => c.method === 'GET' && c.list);
|
|
63
|
+
if (list)
|
|
64
|
+
picks.push({ name: plural, kind: 'list', route: routeOf(list), api: list.name });
|
|
65
|
+
const create = forms ? cands.find((c) => c.method === 'POST' && c.body) : undefined;
|
|
66
|
+
if (create)
|
|
67
|
+
picks.push({ name: `New${one}`, kind: 'create', route: `${routeOf(create)}/new`.replace(/\/\/+/g, '/'), api: create.name });
|
|
68
|
+
const detail = cands.find((c) => c.method === 'GET' && !c.list && c.params.length > 0);
|
|
69
|
+
if (detail)
|
|
70
|
+
picks.push({ name: `${one}Detail`, kind: 'detail', route: routeOf(detail), api: detail.name });
|
|
71
|
+
const edit = forms ? cands.find((c) => (c.method === 'PUT' || c.method === 'PATCH') && c.body) : undefined;
|
|
72
|
+
if (edit)
|
|
73
|
+
picks.push({ name: `Edit${one}`, kind: 'edit', route: routeOf(edit, '/edit'), api: edit.name, ...(detail ? { load: detail.name } : {}) });
|
|
74
|
+
return picks;
|
|
75
|
+
}
|
|
76
|
+
export async function planFeatureAdd(root, adapter, opts) {
|
|
77
|
+
if (!adapter.planEndpoint)
|
|
78
|
+
throw new ToolError('not_supported', 'feature add works in Next.js, React Native, Flutter and Android projects, and this folder is not one of them.', 'For iOS, use a Flutter or React Native project (they build for iOS and Android). Run codz init to see what codz detects here.');
|
|
79
|
+
if (!/^[A-Za-z][A-Za-z0-9 _-]{0,40}$/.test(opts.name))
|
|
80
|
+
throw new ToolError('bad_name', `"${opts.name}" is not a feature name.`, 'Use letters, digits, - or _, for example orders or user-profile.');
|
|
81
|
+
if (!opts.spec && !opts.apis?.length)
|
|
82
|
+
throw new ToolError('bad_args', 'Where do the endpoints come from?', `Pass --spec <openapi.yaml> (endpoints whose path or tag is "${opts.name}"), or --api <endpoint> for endpoints already in the project.`);
|
|
83
|
+
const forms = opts.forms !== false;
|
|
84
|
+
const warnings = [];
|
|
85
|
+
const summary = [];
|
|
86
|
+
const cands = [];
|
|
87
|
+
const endpointNames = [];
|
|
88
|
+
let specFile;
|
|
89
|
+
let specBase = '';
|
|
90
|
+
if (opts.spec) {
|
|
91
|
+
specFile = readText(join(root, opts.spec)) !== null ? join(root, opts.spec) : opts.spec;
|
|
92
|
+
const text = readText(specFile);
|
|
93
|
+
if (text === null)
|
|
94
|
+
throw new ToolError('no_file', `Cannot read ${opts.spec}.`);
|
|
95
|
+
const { ops, doc } = featureOperations(text, opts);
|
|
96
|
+
if (!ops.length) {
|
|
97
|
+
throw new ToolError('no_endpoints', `No operations in ${opts.spec} belong to "${opts.name}" (${doc.operations.length} in the file).`, 'Name the feature after a path segment (orders for /orders/{id}), or pass --tag or --path. See the operations with: codz api import <file> --list');
|
|
98
|
+
}
|
|
99
|
+
const first = ops.find((o) => o.spec.origin);
|
|
100
|
+
if (first)
|
|
101
|
+
specBase = `${first.spec.origin}${first.spec.serverPrefix ?? ''}`;
|
|
102
|
+
for (const o of ops) {
|
|
103
|
+
cands.push({ name: o.spec.name, method: o.spec.method, path: o.spec.path, params: o.spec.pathParams.map((p) => p.name), list: o.spec.response?.kind === 'array', body: !!o.spec.request });
|
|
104
|
+
endpointNames.push(o.spec.name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const ws = openWorkspace(root);
|
|
108
|
+
try {
|
|
109
|
+
// 0. No API layer yet (a fresh project): set one up first, in the same change, so the
|
|
110
|
+
// endpoints, screens and tests below are written in that architecture.
|
|
111
|
+
const before = (await loadMap(ws.root, adapter, loadConfig(ws.root).conventions, { force: true })).map;
|
|
112
|
+
const options = adapter.architectures?.(before) ?? [];
|
|
113
|
+
let setup;
|
|
114
|
+
if (options.length) {
|
|
115
|
+
const pick = opts.architecture ? options.find((o) => o.id === opts.architecture) : (options.find((o) => o.recommended) ?? options[0]);
|
|
116
|
+
if (!pick)
|
|
117
|
+
throw new ToolError('bad_args', `Unknown architecture "${opts.architecture}".`, `Choose one of: ${options.map((o) => o.id).join(', ')}.`);
|
|
118
|
+
const baseUrl = opts.baseUrl ?? (specBase || 'https://api.example.com');
|
|
119
|
+
const { changeSet: setupCs, plan } = planSetup(ws.root, adapter, before, { architecture: pick.id, baseUrl });
|
|
120
|
+
ws.apply(setupCs);
|
|
121
|
+
setup = { architecture: pick.id, dependenciesChanged: plan.dependenciesChanged };
|
|
122
|
+
summary.push(`Set up first: ${pick.label}, base URL ${baseUrl.replace(/\/+$/, '')} (the project had no API layer${opts.architecture ? '' : `; choose another with --architecture ${options.map((o) => o.id).join('|')}`})`);
|
|
123
|
+
}
|
|
124
|
+
else if (opts.architecture) {
|
|
125
|
+
warnings.push(`This project already has an API layer, so --architecture ${opts.architecture} was not used.`);
|
|
126
|
+
}
|
|
127
|
+
// 1. Endpoints.
|
|
128
|
+
if (specFile) {
|
|
129
|
+
// A half-built feature is worse than none: by default a clash stops everything (nothing is written).
|
|
130
|
+
const imp = await planApiImport(ws.root, adapter, { file: specFile, only: endpointNames, baseUrl: opts.baseUrl, skipConflicts: opts.skipConflicts === true, yes: true });
|
|
131
|
+
ws.apply(imp.changeSet);
|
|
132
|
+
summary.push(`Endpoints: ${imp.added.length} added${imp.existing.length ? `, ${imp.existing.length} already in the project` : ''}${imp.skipped.length ? `, ${imp.skipped.length} skipped` : ''}`);
|
|
133
|
+
for (const s of imp.skipped)
|
|
134
|
+
warnings.push(`Skipped ${s.name}: ${s.reason}`);
|
|
135
|
+
warnings.push(...imp.changeSet.warnings.filter((w) => !/^Skipped /.test(w)));
|
|
136
|
+
}
|
|
137
|
+
// Endpoints given by name come from the project as it is (plus the ones just imported).
|
|
138
|
+
const { map } = await loadMap(ws.root, adapter, loadConfig(ws.root).conventions, { force: true });
|
|
139
|
+
for (const n of opts.apis ?? []) {
|
|
140
|
+
const ep = map.endpoints.find((e) => e.name === n) ?? map.endpoints.find((e) => e.name.toLowerCase() === n.toLowerCase());
|
|
141
|
+
if (!ep)
|
|
142
|
+
throw new ToolError('no_endpoint', `No endpoint called ${n} was found.`, `Endpoints: ${map.endpoints.map((e) => e.name).slice(0, 30).join(', ')}`);
|
|
143
|
+
cands.push({ name: ep.name, method: ep.method, path: ep.path, params: [...ep.path.matchAll(/\{([^/}]+)\}/g)].map((m) => m[1]), list: isListType(ep.response), body: ep.method !== 'GET' && ep.method !== 'DELETE' });
|
|
144
|
+
endpointNames.push(ep.name);
|
|
145
|
+
}
|
|
146
|
+
// Spec endpoints that the project already had under another name keep the project's name.
|
|
147
|
+
for (const c of cands) {
|
|
148
|
+
if (map.endpoints.some((e) => e.name === c.name))
|
|
149
|
+
continue;
|
|
150
|
+
const same = map.endpoints.find((e) => e.method === c.method && e.path.replace(/\{[^/}]+\}/g, '{}') === c.path.replace(/\{[^/}]+\}/g, '{}'));
|
|
151
|
+
if (same)
|
|
152
|
+
c.name = same.name;
|
|
153
|
+
}
|
|
154
|
+
// 2. Screens.
|
|
155
|
+
const screens = planScreens(opts.name, cands, forms);
|
|
156
|
+
if (!screens.length)
|
|
157
|
+
warnings.push('None of these endpoints suits a screen (a GET for lists and details, a POST, PUT or PATCH with a body for forms); only the endpoints were added.');
|
|
158
|
+
const made = [];
|
|
159
|
+
const createdBy = new Map();
|
|
160
|
+
for (const [i, s] of screens.entries()) {
|
|
161
|
+
try {
|
|
162
|
+
const cs = await planScreenAdd(ws.root, adapter, { name: s.name, api: s.api, path: s.route, form: s.kind === 'create' || s.kind === 'edit', initial: opts.initial === true && i === 0, ...(s.load ? { load: s.load } : {}) });
|
|
163
|
+
ws.apply(cs);
|
|
164
|
+
createdBy.set(s.name, cs.changes.filter((ch) => ch.before === null && ch.after !== null).map((ch) => ch.path));
|
|
165
|
+
made.push(s);
|
|
166
|
+
warnings.push(...cs.warnings);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (!(e instanceof ToolError))
|
|
170
|
+
throw e;
|
|
171
|
+
warnings.push(`No ${s.kind} screen (${s.name}): ${e.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const s of made)
|
|
175
|
+
summary.push(`Screen ${s.name} (${s.kind}) at ${s.route}, using ${s.api}()`);
|
|
176
|
+
if (made.some((s) => s.kind === 'list')) {
|
|
177
|
+
const linked = await linkScreens(ws, adapter, opts.name, made, createdBy);
|
|
178
|
+
if (linked) {
|
|
179
|
+
summary.push(linked);
|
|
180
|
+
// The row now pushes the detail page itself, so the "open it with Navigator.push" hint is done.
|
|
181
|
+
const detail = made.find((s) => s.kind === 'detail');
|
|
182
|
+
if (detail && /a row opens/.test(linked))
|
|
183
|
+
for (let i = warnings.length - 1; i >= 0; i--)
|
|
184
|
+
if (new RegExp(`open ${detail.name}Page with Navigator\\.push`).test(warnings[i]))
|
|
185
|
+
warnings.splice(i, 1);
|
|
186
|
+
}
|
|
187
|
+
else if (made.length > 1)
|
|
188
|
+
warnings.push('The list screen was not linked to the other screens (its layout is not the one codz writes); add the navigation by hand.');
|
|
189
|
+
}
|
|
190
|
+
// SwiftUI: titles and NavigationLinks need a NavigationStack above the list.
|
|
191
|
+
const listScreen = made.find((s) => s.kind === 'list');
|
|
192
|
+
if (adapter.id === 'ios' && listScreen && !opts.initial) {
|
|
193
|
+
const swift = walk(ws.root, '.', { exts: ['.swift'], extraIgnored: ['Pods', 'DerivedData', 'build'] });
|
|
194
|
+
if (!swift.some((f) => /\bNavigation(Stack|View|SplitView)\b/.test((ws.read(f) ?? '').split('#Preview')[0])))
|
|
195
|
+
warnings.push(`Nothing in the app uses a NavigationStack yet, so ${listScreen.name}View's title and links will not work until one is above it. Run again with --initial to open the app on it inside a NavigationStack, or wrap it yourself.`);
|
|
196
|
+
}
|
|
197
|
+
const unused = cands.filter((c) => !made.some((s) => s.api === c.name)).map((c) => `${c.name}()`);
|
|
198
|
+
if (unused.length)
|
|
199
|
+
summary.push(`Also available for your own screens: ${unused.join(', ')}`);
|
|
200
|
+
// 3. Tests.
|
|
201
|
+
let tested = false;
|
|
202
|
+
if (opts.tests !== false) {
|
|
203
|
+
try {
|
|
204
|
+
const names = [...new Set(cands.map((c) => c.name))];
|
|
205
|
+
const cs = await planTestAdd(ws.root, adapter, { endpoints: names });
|
|
206
|
+
tested = ws.apply(cs);
|
|
207
|
+
if (tested)
|
|
208
|
+
summary.push(`Tests for ${names.length} endpoint${names.length === 1 ? '' : 's'}`);
|
|
209
|
+
warnings.push(...cs.warnings);
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
if (!(e instanceof ToolError))
|
|
213
|
+
throw e;
|
|
214
|
+
warnings.push(`No tests: ${e.message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
summary.unshift(`Feature "${opts.name}": ${new Set(endpointNames).size} endpoint${new Set(endpointNames).size === 1 ? '' : 's'}, ${made.length} screen${made.length === 1 ? '' : 's'}${tested ? ', tests' : ''}`);
|
|
218
|
+
const changeSet = ws.finish('feature add', summary, [...new Set(warnings)]);
|
|
219
|
+
return { changeSet, endpoints: [...new Set(endpointNames)], screens: made, tests: tested, setup };
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
ws.dispose();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Rows of the list open the detail screen; a "New" button opens the create form. Returns a summary line, or null. */
|
|
226
|
+
async function linkScreens(ws, adapter, feature, made, createdBy) {
|
|
227
|
+
const list = made.find((s) => s.kind === 'list');
|
|
228
|
+
const detail = made.find((s) => s.kind === 'detail');
|
|
229
|
+
const create = made.find((s) => s.kind === 'create');
|
|
230
|
+
const edit = made.find((s) => s.kind === 'edit');
|
|
231
|
+
if (!detail && !create)
|
|
232
|
+
return null;
|
|
233
|
+
const { map } = await loadMap(ws.root, adapter, loadConfig(ws.root).conventions, { force: true });
|
|
234
|
+
const c = map.conventions;
|
|
235
|
+
const ep = map.endpoints.find((e) => e.name === list.api);
|
|
236
|
+
const model = ep ? responseShape(ep.response, map).model : undefined;
|
|
237
|
+
const params = detail ? [...detail.route.matchAll(/:(\w+)/g)].map((m) => m[1]) : [];
|
|
238
|
+
const t = {
|
|
239
|
+
// A detail route with one parameter (the item's id) can be opened from a row.
|
|
240
|
+
detail: detail && params.length === 1 && !/:/.test(list.route) ? { route: detail.route, param: params[0], screen: detail.name } : undefined,
|
|
241
|
+
create: create ? { route: create.route, screen: create.name } : undefined,
|
|
242
|
+
idField: idField(model),
|
|
243
|
+
noun: singular(kebab(feature)).replace(/-/g, ' '),
|
|
244
|
+
};
|
|
245
|
+
const files = createdBy.get(list.name) ?? [];
|
|
246
|
+
const pick = (re) => files.find((f) => re.test(f));
|
|
247
|
+
// GetX: modules keep their own route names, so links go by the Routes constant.
|
|
248
|
+
if (adapter.id === 'flutter' && (c.router === 'getx' || c.di === 'getx' || c.stateManagement === 'getx') && c.packageName) {
|
|
249
|
+
const konst = (screen) => konstFor(ws, c, screen);
|
|
250
|
+
if (t.detail)
|
|
251
|
+
t.detail = { ...t.detail, routeConst: konst(t.detail.screen) };
|
|
252
|
+
if (t.create)
|
|
253
|
+
t.create = { ...t.create, routeConst: konst(t.create.screen) };
|
|
254
|
+
t.routesImport = `package:${c.packageName}/${(c.routesFile || 'lib/app/routes/app_pages.dart').replace(/^lib\//, '')}`;
|
|
255
|
+
}
|
|
256
|
+
// Flutter with Navigator: the detail page is pushed by its class, so the list imports it.
|
|
257
|
+
if (adapter.id === 'flutter' && t.detail && detail) {
|
|
258
|
+
const page = (createdBy.get(detail.name) ?? []).find((f) => /^lib\/.*\.dart$/.test(f) && /class \w+ extends \w*(Stateful|Stateless)Widget/.test(ws.read(f) ?? ''));
|
|
259
|
+
const widget = page ? /class (\w+) extends \w*(?:Stateful|Stateless)Widget/.exec(ws.read(page) ?? '')?.[1] : undefined;
|
|
260
|
+
if (page && widget && c.packageName)
|
|
261
|
+
t.detail = { ...t.detail, widget, dartImport: `package:${c.packageName}/${page.slice(4)}` };
|
|
262
|
+
}
|
|
263
|
+
let done = false;
|
|
264
|
+
if (adapter.id === 'nextjs') {
|
|
265
|
+
const f = pick(/(^|\/)page\.(tsx|jsx)$/);
|
|
266
|
+
const text = f ? ws.read(f) : null;
|
|
267
|
+
const out = text ? linkNextList(text, t) : null;
|
|
268
|
+
if (f && out)
|
|
269
|
+
(ws.write(f, out), (done = true));
|
|
270
|
+
}
|
|
271
|
+
else if (adapter.id === 'ios') {
|
|
272
|
+
const f = pick(new RegExp(`${list.name}View\\.swift$`));
|
|
273
|
+
const text = f ? ws.read(f) : null;
|
|
274
|
+
const detailFile = detail ? (createdBy.get(detail.name) ?? []).find((p) => new RegExp(`${detail.name}View\\.swift$`).test(p)) : undefined;
|
|
275
|
+
const init = detailFile ? /\binit\((\w+): (\w+)\)/.exec(ws.read(detailFile) ?? '') : null;
|
|
276
|
+
const idInfo = model?.fields.find((x) => x.name === t.idField);
|
|
277
|
+
const out = text
|
|
278
|
+
? linkIosList(text, {
|
|
279
|
+
detail: t.detail && init ? { view: `${t.detail.screen}View`, param: init[1], type: init[2] } : undefined,
|
|
280
|
+
create: t.create ? { view: `${t.create.screen}View` } : undefined,
|
|
281
|
+
id: idInfo ? { name: idInfo.name, type: idInfo.type } : undefined,
|
|
282
|
+
I: ' '.repeat(Number(c.indent) || 4),
|
|
283
|
+
})
|
|
284
|
+
: null;
|
|
285
|
+
if (f && out)
|
|
286
|
+
(ws.write(f, out), (done = true));
|
|
287
|
+
}
|
|
288
|
+
else if (adapter.id === 'react') {
|
|
289
|
+
const f = pick(new RegExp(`${list.name}Page\\.(tsx|jsx)$`)) ?? pick(/\.(tsx|jsx)$/);
|
|
290
|
+
const text = f ? ws.read(f) : null;
|
|
291
|
+
const out = text ? linkReactList(text, t) : null;
|
|
292
|
+
if (f && out)
|
|
293
|
+
(ws.write(f, out), (done = true));
|
|
294
|
+
}
|
|
295
|
+
else if (adapter.id === 'react-native') {
|
|
296
|
+
const f = pick(new RegExp(`${list.name}Screen\\.(tsx|jsx)$`)) ?? pick(/\.(tsx|jsx)$/);
|
|
297
|
+
const text = f ? ws.read(f) : null;
|
|
298
|
+
const out = text && f ? linkRnList(text, t, c, f) : null;
|
|
299
|
+
if (f && out)
|
|
300
|
+
(ws.write(f, out), (done = true));
|
|
301
|
+
}
|
|
302
|
+
else if (adapter.id === 'flutter') {
|
|
303
|
+
const f = pick(/_(page|view|screen)\.dart$/);
|
|
304
|
+
const text = f ? ws.read(f) : null;
|
|
305
|
+
const out = text ? linkFlutterList(text, t, c) : null;
|
|
306
|
+
if (f && out)
|
|
307
|
+
(ws.write(f, out), (done = true));
|
|
308
|
+
}
|
|
309
|
+
else if (adapter.id === 'android' && c.ui === 'views') {
|
|
310
|
+
const f = pick(new RegExp(`${list.name}Activity\\.kt$`));
|
|
311
|
+
const text = f ? ws.read(f) : null;
|
|
312
|
+
const pkg = text ? /^package\s+([\w.]+)/m.exec(text)?.[1] ?? '' : '';
|
|
313
|
+
const out = text ? linkViewsList(text, pkg, { detail: t.detail ? { activity: `${t.detail.screen}Activity`, param: t.detail.param } : undefined, create: t.create ? { activity: `${t.create.screen}Activity`, noun: t.noun } : undefined, idField: t.idField }) : null;
|
|
314
|
+
if (f && out)
|
|
315
|
+
(ws.write(f, out), (done = true));
|
|
316
|
+
}
|
|
317
|
+
else if (adapter.id === 'android') {
|
|
318
|
+
const f = pick(new RegExp(`${list.name}Screen\\.kt$`));
|
|
319
|
+
const navFile = c.navFile;
|
|
320
|
+
const screen = f ? ws.read(f) : null;
|
|
321
|
+
const nav = navFile ? ws.read(navFile) : null;
|
|
322
|
+
const out = screen && nav ? linkAndroidList(screen, nav, `${list.name}Screen`, t) : null;
|
|
323
|
+
if (f && navFile && out) {
|
|
324
|
+
ws.write(f, out.screen);
|
|
325
|
+
ws.write(navFile, out.navHost);
|
|
326
|
+
done = true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const edited = linkEdit(ws, adapter, c, { detail, edit, create }, createdBy, t);
|
|
330
|
+
if (!done && !edited)
|
|
331
|
+
return null;
|
|
332
|
+
return `Linked the screens: ${[done && t.detail ? `a row opens ${t.detail.screen}` : '', done && t.create ? `"New ${t.noun}" opens ${t.create.screen}` : '', edited].filter(Boolean).join(', ')}`;
|
|
333
|
+
}
|
|
334
|
+
/** GetX: the project's own constant for a module's route (Routes.PRODUCT_DETAIL or Routes.productDetail). */
|
|
335
|
+
function konstFor(ws, c, screen) {
|
|
336
|
+
const pages = c.routesFile || 'lib/app/routes/app_pages.dart';
|
|
337
|
+
const routesText = `${ws.read(pages) ?? ''}\n${ws.read(pages.replace(/[^/]+$/, 'app_routes.dart')) ?? ''}`;
|
|
338
|
+
const pathsBlock = /abstract\s+class\s+_Paths\b[^{]*\{([\s\S]*?)\n\}/.exec(routesText)?.[1] ?? '';
|
|
339
|
+
const routesBlock = /abstract\s+class\s+Routes\b[^{]*\{([\s\S]*?)\n\}/.exec(routesText)?.[1] ?? '';
|
|
340
|
+
const path = `/${kebab(screen)}`;
|
|
341
|
+
const pathName = [...pathsBlock.matchAll(/static\s+const\s+(\w+)\s*=\s*'([^']*)'/g)].find((m) => m[2] === path)?.[1];
|
|
342
|
+
const route = pathName ? [...routesBlock.matchAll(/static\s+const\s+(\w+)\s*=\s*_Paths\.(\w+)/g)].find((m) => m[2] === pathName)?.[1] : undefined;
|
|
343
|
+
return route ?? snake(screen).toUpperCase();
|
|
344
|
+
}
|
|
345
|
+
/** The Edit button on the detail screen (and, on Compose, going back from the forms). Returns what it did, or null. */
|
|
346
|
+
function linkEdit(ws, adapter, c, s, createdBy, t) {
|
|
347
|
+
const { detail, edit, create } = s;
|
|
348
|
+
const detailParams = detail ? [...detail.route.matchAll(/:(\w+)/g)].map((m) => m[1]) : [];
|
|
349
|
+
const editParams = edit ? [...edit.route.matchAll(/:(\w+)/g)].map((m) => m[1]) : [];
|
|
350
|
+
// The form opens for the same item: one shared parameter.
|
|
351
|
+
const e = detail && edit && detailParams.length === 1 && editParams.length === 1 && detailParams[0] === editParams[0] ? { route: edit.route, param: editParams[0], screen: edit.name } : undefined;
|
|
352
|
+
const filesOf = (m) => (m ? createdBy.get(m.name) ?? [] : []);
|
|
353
|
+
const find = (m, re) => filesOf(m).find((f) => re.test(f));
|
|
354
|
+
const update = (f, fn) => {
|
|
355
|
+
const text = f ? ws.read(f) : null;
|
|
356
|
+
const out = text ? fn(text) : null;
|
|
357
|
+
if (f && out && out !== text) {
|
|
358
|
+
ws.write(f, out);
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
};
|
|
363
|
+
const msg = e ? `"Edit" on ${detail.name} opens ${edit.name}` : null;
|
|
364
|
+
if (adapter.id === 'android' && c.ui !== 'views') {
|
|
365
|
+
const f = find(detail, new RegExp(`${detail?.name}Screen\\.kt$`));
|
|
366
|
+
const navFile = c.navFile;
|
|
367
|
+
const screen = f ? ws.read(f) : null;
|
|
368
|
+
const nav = navFile ? ws.read(navFile) : null;
|
|
369
|
+
if (!nav || !navFile)
|
|
370
|
+
return null;
|
|
371
|
+
const forms = [create, edit].filter((m) => !!m).map((m) => `${m.name}Screen`);
|
|
372
|
+
const pkg = screen ? /^package\s+([\w.]+)/m.exec(screen)?.[1] ?? '' : '';
|
|
373
|
+
const out = editComposeDetail(screen ?? '', nav, `${detail?.name}Screen`, forms, screen ? e : undefined, pkg);
|
|
374
|
+
if (!out)
|
|
375
|
+
return null;
|
|
376
|
+
if (f && out.screen !== screen)
|
|
377
|
+
ws.write(f, out.screen);
|
|
378
|
+
ws.write(navFile, out.navHost);
|
|
379
|
+
const backs = forms.filter((x) => out.navHost.includes(`${x}(onDone = `));
|
|
380
|
+
return [out.screen !== screen ? msg : null, backs.length ? `${backs.map((b) => b.replace(/Screen$/, '')).join(' and ')} go back when saved` : null].filter(Boolean).join(', ') || null;
|
|
381
|
+
}
|
|
382
|
+
if (!e || !detail || !edit)
|
|
383
|
+
return null;
|
|
384
|
+
let ok = false;
|
|
385
|
+
if (adapter.id === 'nextjs')
|
|
386
|
+
ok = update(find(detail, /(^|\/)page\.(tsx|jsx)$/), (x) => editNextDetail(x, e));
|
|
387
|
+
else if (adapter.id === 'react')
|
|
388
|
+
ok = update(find(detail, new RegExp(`${detail.name}Page\\.(tsx|jsx)$`)), (x) => editReactDetail(x, e));
|
|
389
|
+
else if (adapter.id === 'react-native')
|
|
390
|
+
ok = update(find(detail, new RegExp(`(${detail.name}Screen|\\[${e.param}\\])\\.(tsx|jsx)$`)), (x) => editRnDetail(x, e, c));
|
|
391
|
+
else if (adapter.id === 'flutter') {
|
|
392
|
+
const getx = c.router === 'getx' || c.di === 'getx' || c.stateManagement === 'getx';
|
|
393
|
+
let target = e;
|
|
394
|
+
if (getx)
|
|
395
|
+
target = { ...e, routeConst: c.packageName ? konstFor(ws, c, edit.name) : undefined, routesImport: t.routesImport };
|
|
396
|
+
else if (c.router === 'navigator') {
|
|
397
|
+
const page = filesOf(edit).find((f) => /^lib\/.*\.dart$/.test(f) && /class \w+ extends \w*(Stateful|Stateless)Widget/.test(ws.read(f) ?? ''));
|
|
398
|
+
const widget = page ? /class (\w+) extends \w*(?:Stateful|Stateless)Widget/.exec(ws.read(page) ?? '')?.[1] : undefined;
|
|
399
|
+
if (page && widget && c.packageName)
|
|
400
|
+
target = { ...e, widget, dartImport: `package:${c.packageName}/${page.slice(4)}` };
|
|
401
|
+
}
|
|
402
|
+
const controller = getx ? find(detail, /_controller\.dart$/) : undefined;
|
|
403
|
+
ok = update(find(detail, /_(page|view|screen)\.dart$/), (x) => editFlutterDetail(x, target, c, controller ? ws.read(controller) ?? undefined : undefined));
|
|
404
|
+
}
|
|
405
|
+
else if (adapter.id === 'android') {
|
|
406
|
+
const f = find(detail, new RegExp(`${detail.name}Activity\\.kt$`));
|
|
407
|
+
ok = update(f, (x) => editViewsDetail(x, /^package\s+([\w.]+)/m.exec(x)?.[1] ?? '', { activity: `${edit.name}Activity`, param: e.param }));
|
|
408
|
+
}
|
|
409
|
+
else if (adapter.id === 'ios') {
|
|
410
|
+
const df = find(detail, new RegExp(`${detail.name}View\\.swift$`));
|
|
411
|
+
const ef = find(edit, new RegExp(`${edit.name}View\\.swift$`));
|
|
412
|
+
const init = df ? /\binit\((\w+): ([\w?[\]]+)\)/.exec(ws.read(df) ?? '') : null;
|
|
413
|
+
const einit = ef ? /\binit\((\w+): ([\w?[\]]+)\)/.exec(ws.read(ef) ?? '') : null;
|
|
414
|
+
if (init && einit && init[1] === einit[1] && init[2] === einit[2])
|
|
415
|
+
ok = update(df, (x) => editIosDetail(x, { view: `${edit.name}View`, param: init[1], type: init[2] }, ' '.repeat(Number(c.indent) || 4)));
|
|
416
|
+
}
|
|
417
|
+
return ok ? msg : null;
|
|
418
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Connects the detail screen feature add made to its edit form: an Edit button
|
|
2
|
+
// on the detail screen opens the form for the same item, and the detail screen
|
|
3
|
+
// shows the saved values when it comes back. Like featureLinks, each function
|
|
4
|
+
// edits the screen screen add wrote (a known shape) and returns null when that
|
|
5
|
+
// shape is not there.
|
|
6
|
+
import { addKotlinImports } from "../adapters/kotlinGen.js";
|
|
7
|
+
const fill = (route, param, expr) => route.replace(`:${param}`, expr);
|
|
8
|
+
/** Next.js server page: an Edit link under the heading. */
|
|
9
|
+
export function editNextDetail(text, e) {
|
|
10
|
+
if (!new RegExp(`const \\{ ${e.param} \\} = await params`).test(text))
|
|
11
|
+
return null;
|
|
12
|
+
const re = /(\n([ \t]*)<h1>[^<]*<\/h1>)/;
|
|
13
|
+
if (!re.test(text))
|
|
14
|
+
return null;
|
|
15
|
+
let out = text.replace(re, (_m, h, indent) => `${h}\n${indent}<p>\n${indent} <Link href={\`${fill(e.route, e.param, `\${${e.param}}`)}\`}>Edit</Link>\n${indent}</p>`);
|
|
16
|
+
if (!/from ['"]next\/link['"]/.test(out))
|
|
17
|
+
out = `import Link from 'next/link';\n${out}`;
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** React Router page: an Edit link under the heading (the page loads again when it is opened again). */
|
|
21
|
+
export function editReactDetail(text, e) {
|
|
22
|
+
if (!new RegExp(`const \\{ ${e.param}(?: = '')? \\} = useParams\\(\\)`).test(text))
|
|
23
|
+
return null;
|
|
24
|
+
const re = /(\n([ \t]*)<h1>[^<]*<\/h1>)/;
|
|
25
|
+
if (!re.test(text))
|
|
26
|
+
return null;
|
|
27
|
+
let out = text.replace(re, (_m, h, indent) => `${h}\n${indent}<p>\n${indent} <Link to={\`${fill(e.route, e.param, `\${${e.param}}`)}\`}>Edit</Link>\n${indent}</p>`);
|
|
28
|
+
out = out.replace(/import \{ ([^}]*) \} from 'react-router-dom';/, (m, names) => {
|
|
29
|
+
const set = new Set(names.split(',').map((s) => s.trim()).filter(Boolean));
|
|
30
|
+
if (set.has('Link'))
|
|
31
|
+
return m;
|
|
32
|
+
set.add('Link');
|
|
33
|
+
return `import { ${[...set].sort().join(', ')} } from 'react-router-dom';`;
|
|
34
|
+
});
|
|
35
|
+
if (!/\bLink\b[^\n]*from 'react-router-dom'/.test(out))
|
|
36
|
+
out = `import { Link } from 'react-router-dom';\n${out}`;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
const LOAD_EFFECT = ' useEffect(() => {\n load();\n }, [load]);';
|
|
40
|
+
/**
|
|
41
|
+
* React Native: an Edit button at the top of the details; the screen loads again
|
|
42
|
+
* whenever it comes back into focus (React Navigation keeps it mounted under the form).
|
|
43
|
+
*/
|
|
44
|
+
export function editRnDetail(text, e, c) {
|
|
45
|
+
const expoRouter = c.router === 'expo-router';
|
|
46
|
+
if (!text.includes(LOAD_EFFECT))
|
|
47
|
+
return null;
|
|
48
|
+
const re = /(\n([ \t]*)<ScrollView contentContainerStyle=\{styles\.details\}>)/;
|
|
49
|
+
if (!re.test(text))
|
|
50
|
+
return null;
|
|
51
|
+
let out = text;
|
|
52
|
+
if (expoRouter) {
|
|
53
|
+
if (!new RegExp(`const \\{ ${e.param} \\} = useLocalSearchParams`).test(out))
|
|
54
|
+
return null;
|
|
55
|
+
out = out.replace(re, (_m, all, indent) => `${all}\n${indent} <Button title="Edit" onPress={() => router.push(\`${fill(e.route, e.param, `\${${e.param}}`)}\`)} />`);
|
|
56
|
+
out = out.replace(LOAD_EFFECT, ' // Load when the screen opens and again when coming back to it (for example from Edit).\n useFocusEffect(\n useCallback(() => {\n load();\n }, [load]),\n );');
|
|
57
|
+
out = out.replace(/import \{ ([^}]*) \} from 'expo-router';/, (_m, names) => {
|
|
58
|
+
const set = new Set(names.split(',').map((s) => s.trim()).filter(Boolean));
|
|
59
|
+
set.add('router');
|
|
60
|
+
set.add('useFocusEffect');
|
|
61
|
+
return `import { ${[...set].sort().join(', ')} } from 'expo-router';`;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const sig = /\(\{ route \}: NativeStackScreenProps</;
|
|
66
|
+
if (!sig.test(out))
|
|
67
|
+
return null;
|
|
68
|
+
out = out.replace(sig, '({ navigation, route }: NativeStackScreenProps<');
|
|
69
|
+
out = out.replace(re, (_m, all, indent) => `${all}\n${indent} <Button title="Edit" onPress={() => navigation.navigate('${e.screen}', { ${e.param} })} />`);
|
|
70
|
+
out = out.replace(LOAD_EFFECT, " // Load when the screen opens and again when coming back to it (for example from Edit).\n useEffect(\n () =>\n navigation.addListener('focus', () => {\n load();\n }),\n [navigation, load],\n );");
|
|
71
|
+
}
|
|
72
|
+
// useEffect may no longer be used (Expo Router).
|
|
73
|
+
if (!/\buseEffect\(/.test(out))
|
|
74
|
+
out = out.replace(/import \{ ([^}]*) \} from 'react';/, (_m, names) => `import { ${names.split(',').map((s) => s.trim()).filter((s) => s && s !== 'useEffect').join(', ')} } from 'react';`);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Flutter: an Edit action in the app bar; the page loads again when the form closes. */
|
|
78
|
+
export function editFlutterDetail(text, e, c, controllerText) {
|
|
79
|
+
const getx = c.router === 'getx' || c.di === 'getx' || c.stateManagement === 'getx';
|
|
80
|
+
const re = /\n([ \t]*)appBar: AppBar\(title: (const Text\([^\n]*?\))\),/;
|
|
81
|
+
if (!re.test(text))
|
|
82
|
+
return null;
|
|
83
|
+
let onPressed;
|
|
84
|
+
let wrap = (b) => b;
|
|
85
|
+
if (getx) {
|
|
86
|
+
if (!e.routeConst)
|
|
87
|
+
return null;
|
|
88
|
+
// The controller loads in onInit: if (productId != null) loadProduct(int.parse(productId));
|
|
89
|
+
const load = controllerText ? new RegExp(`if \\(${e.param} != null\\) (\\w+\\([^;\\n]*\\));`).exec(controllerText)?.[1] : undefined;
|
|
90
|
+
const reload = load ? `?.then((_) => controller.${load.replace(new RegExp(`\\b${e.param}\\b`, 'g'), 'id')})` : '';
|
|
91
|
+
onPressed = `() {\n final id = Get.parameters['${e.param}'] ?? '';\n Get.toNamed(Routes.${e.routeConst}, parameters: {'${e.param}': id})${reload};\n}`;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const router = c.router;
|
|
95
|
+
if (router !== 'go_router' && router !== 'navigator')
|
|
96
|
+
return null;
|
|
97
|
+
const stateless = /extends StatelessWidget/.test(text);
|
|
98
|
+
const id = stateless ? e.param : `widget.${e.param}`;
|
|
99
|
+
let open;
|
|
100
|
+
if (router === 'go_router')
|
|
101
|
+
open = `context.push('${fill(e.route, e.param, stateless ? `$${id}` : `\${${id}}`)}')`;
|
|
102
|
+
else if (e.widget)
|
|
103
|
+
open = `Navigator.push(context, MaterialPageRoute(builder: (_) => ${e.widget}(${e.param}: ${id})))`;
|
|
104
|
+
else
|
|
105
|
+
return null;
|
|
106
|
+
let reload = '';
|
|
107
|
+
if (/void _reload\(\)/.test(text))
|
|
108
|
+
reload = '.then((_) => _reload())';
|
|
109
|
+
else {
|
|
110
|
+
// Bloc: the page's cubit loads again (the action needs a context below the BlocProvider).
|
|
111
|
+
const cubit = /BlocBuilder<(\w+), \w+>/.exec(text)?.[1];
|
|
112
|
+
if (cubit && text.includes(`context.read<${cubit}>().load()`)) {
|
|
113
|
+
reload = `.then((_) => context.read<${cubit}>().load())`;
|
|
114
|
+
wrap = (b) => `Builder(\n builder: (context) => ${b.replace(/\n/g, '\n ')},\n)`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
onPressed = `() => ${open}${reload}`;
|
|
118
|
+
}
|
|
119
|
+
const button = wrap(`IconButton(\n tooltip: 'Edit',\n icon: const Icon(Icons.edit),\n onPressed: ${onPressed.replace(/\n/g, '\n ')},\n)`);
|
|
120
|
+
let out = text.replace(re, (_m, indent, title) => {
|
|
121
|
+
const b = button.replace(/\n/g, `\n${indent} `);
|
|
122
|
+
return `\n${indent}appBar: AppBar(\n${indent} title: ${title},\n${indent} actions: [\n${indent} ${b},\n${indent} ],\n${indent}),`;
|
|
123
|
+
});
|
|
124
|
+
if (getx) {
|
|
125
|
+
if (!/package:get\/get\.dart/.test(out))
|
|
126
|
+
out = out.replace(/(import 'package:flutter\/material\.dart';\n)/, `$1import 'package:get/get.dart';\n`);
|
|
127
|
+
if (e.routesImport && !out.includes(e.routesImport))
|
|
128
|
+
out = out.replace(/(import 'package:get\/get\.dart';\n)/, `$1\nimport '${e.routesImport}';\n`);
|
|
129
|
+
}
|
|
130
|
+
else if (c.router === 'go_router') {
|
|
131
|
+
if (!/package:go_router\/go_router\.dart/.test(out))
|
|
132
|
+
out = out.replace(/(import 'package:flutter\/material\.dart';\n)/, `$1import 'package:go_router/go_router.dart';\n`);
|
|
133
|
+
}
|
|
134
|
+
else if (e.dartImport && !out.includes(`'${e.dartImport}'`)) {
|
|
135
|
+
out = out.replace(/(import 'package:flutter\/material\.dart';\n)/, `$1import '${e.dartImport}';\n`);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Android Compose: the detail screen takes onEdit and reloads when it is resumed again;
|
|
141
|
+
* the NavHost opens the form from it, and both forms go back when they are saved.
|
|
142
|
+
*/
|
|
143
|
+
export function editComposeDetail(screen, navHost, detailName, forms, e, pkg) {
|
|
144
|
+
let s = screen;
|
|
145
|
+
let nav = navHost;
|
|
146
|
+
let changed = false;
|
|
147
|
+
if (e) {
|
|
148
|
+
const sig = new RegExp(`fun ${detailName}\\(viewModel: `);
|
|
149
|
+
const state = /\n([ \t]*)val state by viewModel\.state\.collectAsState\(\)\n/;
|
|
150
|
+
if (sig.test(s) && state.test(s) && /Scaffold \{ padding ->/.test(s)) {
|
|
151
|
+
s = s.replace(sig, `fun ${detailName}(onEdit: () -> Unit = {}, viewModel: `);
|
|
152
|
+
s = s.replace(state, (m, i) => [
|
|
153
|
+
m.replace(/\n$/, ''),
|
|
154
|
+
`${i}// Load again when coming back to this screen (for example after Edit).`,
|
|
155
|
+
`${i}val owner = LocalLifecycleOwner.current`,
|
|
156
|
+
`${i}DisposableEffect(owner) {`,
|
|
157
|
+
`${i} var first = true`,
|
|
158
|
+
`${i} val observer = LifecycleEventObserver { _, event ->`,
|
|
159
|
+
`${i} if (event == Lifecycle.Event.ON_RESUME) {`,
|
|
160
|
+
`${i} if (!first) viewModel.load()`,
|
|
161
|
+
`${i} first = false`,
|
|
162
|
+
`${i} }`,
|
|
163
|
+
`${i} }`,
|
|
164
|
+
`${i} owner.lifecycle.addObserver(observer)`,
|
|
165
|
+
`${i} onDispose { owner.lifecycle.removeObserver(observer) }`,
|
|
166
|
+
`${i}}`,
|
|
167
|
+
'',
|
|
168
|
+
].join('\n'));
|
|
169
|
+
s = s.replace(/Scaffold \{ padding ->/, 'Scaffold(\n floatingActionButton = { FloatingActionButton(onClick = onEdit) { Text("Edit") } },\n ) { padding ->');
|
|
170
|
+
s = addKotlinImports(s, ['androidx.compose.material3.FloatingActionButton', 'androidx.compose.runtime.DisposableEffect', 'androidx.compose.ui.platform.LocalLifecycleOwner', 'androidx.lifecycle.Lifecycle', 'androidx.lifecycle.LifecycleEventObserver'], pkg);
|
|
171
|
+
const call = new RegExp(`(\\{\\s*\\n\\s*)${detailName}\\(\\)`);
|
|
172
|
+
const target = fill(e.route.replace(/^\//, ''), e.param, `\${it.arguments?.getString("${e.param}")}`);
|
|
173
|
+
if (call.test(nav)) {
|
|
174
|
+
nav = nav.replace(call, `$1${detailName}(onEdit = { navController.navigate("${target}") })`);
|
|
175
|
+
changed = true;
|
|
176
|
+
}
|
|
177
|
+
else
|
|
178
|
+
s = screen;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Forms: go back to the previous screen once saved.
|
|
182
|
+
for (const f of forms) {
|
|
183
|
+
const call = new RegExp(`(\\n\\s*)${f}\\(\\)`);
|
|
184
|
+
if (call.test(nav)) {
|
|
185
|
+
nav = nav.replace(call, `$1${f}(onDone = { navController.popBackStack() })`);
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return changed ? { screen: s, navHost: nav } : null;
|
|
190
|
+
}
|
|
191
|
+
/** Android Views: an Edit button above the details; the Activity loads again when it is shown again. */
|
|
192
|
+
export function editViewsDetail(text, pkg, e) {
|
|
193
|
+
const anchor = ' findViewById<Button>(R.id.retry).setOnClickListener { viewModel.load() }';
|
|
194
|
+
if (!text.includes(anchor))
|
|
195
|
+
return null;
|
|
196
|
+
const cls = /class (\w+) : \w*Activity\(\)/.exec(text)?.[1];
|
|
197
|
+
if (!cls)
|
|
198
|
+
return null;
|
|
199
|
+
let out = text.replace(anchor, [
|
|
200
|
+
anchor,
|
|
201
|
+
' findViewById<LinearLayout>(R.id.content).addView(',
|
|
202
|
+
' Button(this).apply {',
|
|
203
|
+
' text = "Edit"',
|
|
204
|
+
` setOnClickListener { startActivity(Intent(this@${cls}, ${e.activity}::class.java).putExtra("${e.param}", intent.getStringExtra("${e.param}"))) }`,
|
|
205
|
+
' },',
|
|
206
|
+
' 1,',
|
|
207
|
+
' )',
|
|
208
|
+
].join('\n'));
|
|
209
|
+
out = out.replace(/\n([ \t]*)private fun render\(/, (m, i) => `\n${i}// Back from another screen (for example Edit): show the saved values.\n${i}override fun onRestart() {\n${i} super.onRestart()\n${i} viewModel.load()\n${i}}\n${m}`);
|
|
210
|
+
return addKotlinImports(out, ['android.content.Intent', 'android.widget.Button', 'android.widget.LinearLayout'], pkg);
|
|
211
|
+
}
|
|
212
|
+
/** SwiftUI: an Edit link in the toolbar (the detail view loads again when it appears again). */
|
|
213
|
+
export function editIosDetail(text, e, I) {
|
|
214
|
+
const init = new RegExp(`\\n([ \\t]*)init\\(${e.param}: ${e.type.replace(/[?[\]]/g, '\\$&')}\\) \\{\\n`);
|
|
215
|
+
const title = /\n([ \t]*)\.navigationTitle\("[^"\n]*"\)\n/;
|
|
216
|
+
if (!init.test(text) || !title.test(text))
|
|
217
|
+
return null;
|
|
218
|
+
const model = /(\n([ \t]*)@StateObject private var model: \w+\n)/;
|
|
219
|
+
if (!model.test(text))
|
|
220
|
+
return null;
|
|
221
|
+
let out = text.replace(model, (m, _a, i) => `${m}${i}private let ${e.param}: ${e.type}\n`);
|
|
222
|
+
out = out.replace(init, (m, i) => `${m}${i}${I}self.${e.param} = ${e.param}\n`);
|
|
223
|
+
out = out.replace(title, (m, i) => `${m}${i}.toolbar {\n${i}${I}NavigationLink("Edit") {\n${i}${I}${I}${e.view}(${e.param}: ${e.param})\n${i}${I}}\n${i}}\n`);
|
|
224
|
+
return out;
|
|
225
|
+
}
|