create-ui5-freestyle-lr 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +141 -0
  2. package/package.json +34 -0
  3. package/src/cli.js +74 -0
  4. package/src/metadata/fetch.js +42 -0
  5. package/src/metadata/parse.js +104 -0
  6. package/src/prompts/basic.js +43 -0
  7. package/src/prompts/fields.js +144 -0
  8. package/src/prompts/odata.js +57 -0
  9. package/src/render/buildContext.js +67 -0
  10. package/src/render/controlForType.js +136 -0
  11. package/src/render/engine.js +81 -0
  12. package/src/templates/README.md.ejs +62 -0
  13. package/src/templates/_valueHelp.fragment.xml.ejs +17 -0
  14. package/src/templates/package.json.ejs +14 -0
  15. package/src/templates/ui5.yaml.ejs +28 -0
  16. package/src/templates/webapp/Component.js.ejs +47 -0
  17. package/src/templates/webapp/controller/App.controller.js +29 -0
  18. package/src/templates/webapp/controller/BaseController.js +35 -0
  19. package/src/templates/webapp/controller/ErrorHandler.js +71 -0
  20. package/src/templates/webapp/controller/NotFound.controller.js +12 -0
  21. package/src/templates/webapp/controller/Worklist.controller.js.ejs +1158 -0
  22. package/src/templates/webapp/css/style.css +17 -0
  23. package/src/templates/webapp/i18n/i18n.properties.ejs +83 -0
  24. package/src/templates/webapp/i18n/i18n_de.properties.ejs +83 -0
  25. package/src/templates/webapp/index.html.ejs +52 -0
  26. package/src/templates/webapp/localService/backendCheck.js.ejs +52 -0
  27. package/src/templates/webapp/localService/mockserver.js.ejs +29 -0
  28. package/src/templates/webapp/manifest.json.ejs +106 -0
  29. package/src/templates/webapp/model/formatter.js +148 -0
  30. package/src/templates/webapp/model/models.js +23 -0
  31. package/src/templates/webapp/view/App.view.xml +10 -0
  32. package/src/templates/webapp/view/NotFound.view.xml +12 -0
  33. package/src/templates/webapp/view/Worklist.view.xml.ejs +173 -0
  34. package/src/templates/webapp/view/fragments/GroupDialog.fragment.xml.ejs +11 -0
  35. package/src/templates/webapp/view/fragments/InfoPopover.fragment.xml +38 -0
  36. package/src/templates/webapp/view/fragments/SortDialog.fragment.xml.ejs +11 -0
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # create-ui5-freestyle-lr
2
+
3
+ Scaffolder for **freestyle SAPUI5 List Report** apps. Ask a few questions, fetch
4
+ your OData `$metadata`, and get a running UI5 app with filter bar, value helps,
5
+ pagination, sorting, grouping, column settings, and i18n — all plain SAPUI5
6
+ code you own and can edit.
7
+
8
+ > Not Fiori Elements (annotation-driven). This is a freestyle template that
9
+ > generates explicit views and controllers you can customise directly.
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ npm create ui5-freestyle-lr@latest my-app
15
+ cd my-app
16
+ npm install
17
+ npm start
18
+ ```
19
+
20
+ The CLI asks a short series of questions (app namespace, OData URL, credentials),
21
+ fetches the `$metadata`, then lets you pick columns, filters, value helps, and
22
+ search fields interactively from the live entity set.
23
+
24
+ Afterwards the generated project is yours — plain UI5 + `@ui5/cli` v3,
25
+ no runtime dependency on this package.
26
+
27
+ ## What you get
28
+
29
+ ```
30
+ my-app/
31
+ ├── package.json @ui5/cli v3 scripts (start / build)
32
+ ├── ui5.yaml SAPUI5 framework config + OData proxy
33
+ ├── README.md How-to for the generated project
34
+ └── webapp/
35
+ ├── Component.js, manifest.json, index.html
36
+ ├── view/
37
+ │ ├── App.view.xml
38
+ │ ├── Worklist.view.xml DynamicPage + VariantManagement + FilterBar + Table + Footer pagination
39
+ │ ├── NotFound.view.xml
40
+ │ └── fragments/
41
+ │ ├── SortDialog.fragment.xml
42
+ │ ├── GroupDialog.fragment.xml
43
+ │ ├── InfoPopover.fragment.xml (demo pattern)
44
+ │ └── <Field>VH.fragment.xml (one per value-help filter)
45
+ ├── controller/
46
+ │ ├── App.controller.js
47
+ │ ├── BaseController.js helpers (getRouter, getModel, getResourceBundle, onNavBack)
48
+ │ ├── Worklist.controller.js pagination, sort/group, column settings, filter build, VH handlers
49
+ │ ├── ErrorHandler.js centralised OData error handling
50
+ │ └── NotFound.controller.js
51
+ ├── model/
52
+ │ ├── formatter.js formatDate / formatPartnerId / formatStrongCode / numberUnit / _animateValue
53
+ │ └── models.js device + FLP model factories
54
+ ├── i18n/ i18n.properties (+ i18n_de.properties if German enabled)
55
+ ├── css/style.css
56
+ └── localService/metadata.xml snapshot of your OData $metadata
57
+ ```
58
+
59
+ ## Features in the generated app
60
+
61
+ - **DynamicPage** layout with collapsible filter bar header
62
+ - **VariantManagement** (`sap.ui.fl.variants.VariantManagement`) for "My View"
63
+ save / load of the current filter, sort, group, column visibility, page
64
+ size, and search. Persisted client-side via the `LocalStorageConnector`
65
+ (one-line swap to `LrepConnector` for SAP-backend storage).
66
+ - **FilterBar** with three filter input types, picked automatically from the
67
+ OData property type:
68
+ - `DateRangeSelection` for `Edm.DateTime` / `Edm.DateTimeOffset`
69
+ - `MultiInput` + value-help dialog when you point a filter at a lookup EntitySet
70
+ - Plain `MultiInput` for free-text contains-search
71
+ - **Table** columns auto-sized by Edm type:
72
+ - First key field → `ObjectIdentifier`
73
+ - Dates → `Text` with the `formatDate` formatter
74
+ - Booleans → accept/decline icon
75
+ - Numbers → `Text` with `numberUnit` formatter (right-aligned)
76
+ - Strings → plain `Text`
77
+ - **Excel export** (`sap.ui.export.Spreadsheet`) — refetches all matching
78
+ rows respecting current filters and sort, with a confirm dialog above
79
+ 1 000 rows. Column types are derived from the OData metadata at scaffold
80
+ time, so dates land as Excel date cells. Animated progress dialog while
81
+ the file is generated.
82
+ - **Server-side pagination** with `$top` / `$skip` + a separate `$count` query
83
+ for total pages. Footer has page combo, step input for rows-per-page,
84
+ prev/next/first/last buttons.
85
+ - **Auto-refresh** toggle button — re-fetches the table every 30 s while
86
+ active. The footer shows the last-refreshed timestamp as an
87
+ `ObjectStatus` + `<cite>` formatted text.
88
+ - **Sharp table corners** (CSS class `lrTable`) — `border-radius: 0` on the
89
+ table root, header toolbar, and info toolbar for a flatter monitoring
90
+ look.
91
+ - **Sort dialog**, **group dialog**, **column settings dialog** (all from the
92
+ table header toolbar).
93
+ - **Info bar** that shows a human-readable summary of the active filter,
94
+ search, sort, and group state.
95
+ - **Search bar** across the columns you pick (joined with `or`).
96
+ - **InfoPopover** demo pattern — click a row-level link, open a popover with
97
+ row-specific details. Replace the fields to match your own data.
98
+ - **Centralised ErrorHandler** attached to the OData model.
99
+ - **Scroll-to-top animation** after page change.
100
+ - **URL state persistence** — search / sort / group / page mirrored to the
101
+ hash so a refresh or shared link preserves the view.
102
+ - **i18n** (English + optional German).
103
+ - **localService/metadata.xml** snapshot so offline development works.
104
+
105
+ ## Prompts
106
+
107
+ ```
108
+ ? App namespace (com.company.module.app)
109
+ ? App title
110
+ ? App description
111
+ ? Minimum UI5 version (default 1.120.0)
112
+ ? Include German translations? (default yes)
113
+
114
+ ? OData metadata source (fetch live / provide local file)
115
+ ? Service URL
116
+ ? Username / password
117
+ ? Allow self-signed certificates? (default yes, for on-prem dev systems)
118
+
119
+ ? Main EntitySet (dropdown from $metadata)
120
+ ? Table columns (checkbox, all checked by default)
121
+ ? Filter bar fields (checkbox)
122
+ ? For each filter: (plain MultiInput / with ValueHelp EntitySet)
123
+ ? Search fields (string properties only)
124
+ ? Default sort field + direction
125
+ ```
126
+
127
+ ## Adding a filter / column / value help after scaffolding
128
+
129
+ The generated project has a section at the bottom of its own `README.md` with
130
+ a step-by-step copy-paste recipe. The template is one-shot: re-running the
131
+ CLI creates a new project; changes to an existing one are done by hand.
132
+
133
+ ## Requirements
134
+
135
+ - Node.js 18+
136
+ - For live metadata fetch: network access to the SAP OData service and
137
+ credentials the service accepts (basic auth).
138
+
139
+ ## Licence
140
+
141
+ MIT
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "create-ui5-freestyle-lr",
3
+ "version": "0.2.0",
4
+ "description": "Scaffolder for freestyle SAPUI5 List Report apps with dynamic FilterBar, pagination, value helps, sort/group dialogs, and i18n.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-ui5-freestyle-lr": "src/cli.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "sapui5",
15
+ "ui5",
16
+ "fiori",
17
+ "list-report",
18
+ "scaffolder",
19
+ "create",
20
+ "freestyle",
21
+ "odata"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "@inquirer/prompts": "^5.3.8",
28
+ "axios": "^1.7.0",
29
+ "ejs": "^3.1.10",
30
+ "fast-xml-parser": "^4.4.0",
31
+ "picocolors": "^1.0.1"
32
+ },
33
+ "license": "MIT"
34
+ }
package/src/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
5
+ import pc from "picocolors";
6
+ import { runBasicPrompts } from "./prompts/basic.js";
7
+ import { runOdataPrompts } from "./prompts/odata.js";
8
+ import { runFieldPrompts } from "./prompts/fields.js";
9
+ import { render } from "./render/engine.js";
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+
14
+ function printBanner() {
15
+ console.log();
16
+ console.log(pc.bold(pc.cyan("create-ui5-freestyle-lr")));
17
+ console.log(pc.dim("Scaffold a freestyle SAPUI5 List Report app"));
18
+ console.log();
19
+ }
20
+
21
+ function resolveTarget(rawArg) {
22
+ if (!rawArg) {
23
+ console.error(pc.red("Error: target directory is required"));
24
+ console.error(pc.dim("Usage: npm create ui5-freestyle-lr <project-name>"));
25
+ process.exit(1);
26
+ }
27
+ const targetDir = path.resolve(process.cwd(), rawArg);
28
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
29
+ console.error(pc.red(`Error: directory "${rawArg}" exists and is not empty`));
30
+ process.exit(1);
31
+ }
32
+ mkdirSync(targetDir, { recursive: true });
33
+ return targetDir;
34
+ }
35
+
36
+ async function main() {
37
+ printBanner();
38
+
39
+ const rawArg = process.argv[2];
40
+ const targetDir = resolveTarget(rawArg);
41
+
42
+ const basic = await runBasicPrompts();
43
+ const odata = await runOdataPrompts();
44
+ const fields = await runFieldPrompts(odata.metadata);
45
+
46
+ const answers = {
47
+ ...basic,
48
+ serviceUrl: odata.serviceUrl,
49
+ rawMetadataXml: odata.rawMetadataXml,
50
+ ...fields,
51
+ };
52
+
53
+ console.log();
54
+ console.log(pc.dim("Rendering project..."));
55
+ await render(answers, targetDir);
56
+
57
+ console.log();
58
+ console.log(pc.green("Project created."));
59
+ console.log();
60
+ console.log(pc.dim("Next steps:"));
61
+ console.log(` cd ${path.relative(process.cwd(), targetDir) || "."}`);
62
+ console.log(" npm install");
63
+ console.log(" npm start");
64
+ console.log();
65
+ }
66
+
67
+ main().catch((err) => {
68
+ if (err && err.name === "ExitPromptError") {
69
+ console.log(pc.dim("\nAborted."));
70
+ process.exit(0);
71
+ }
72
+ console.error(pc.red("\nFailed:"), err?.message || err);
73
+ process.exit(1);
74
+ });
@@ -0,0 +1,42 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import https from "node:https";
3
+ import axios from "axios";
4
+
5
+ function buildMetadataUrl(serviceUrl) {
6
+ const trimmed = serviceUrl.trim().replace(/\/+$/, "");
7
+ return `${trimmed}/$metadata`;
8
+ }
9
+
10
+ export async function fetchMetadataFromUrl({ serviceUrl, username, password, insecure }) {
11
+ const url = buildMetadataUrl(serviceUrl);
12
+
13
+ const config = {
14
+ responseType: "text",
15
+ headers: { Accept: "application/xml" },
16
+ timeout: 30000,
17
+ transformResponse: [(data) => data],
18
+ };
19
+
20
+ if (username) {
21
+ config.auth = { username, password: password || "" };
22
+ }
23
+
24
+ if (insecure) {
25
+ config.httpsAgent = new https.Agent({ rejectUnauthorized: false });
26
+ }
27
+
28
+ const response = await axios.get(url, config);
29
+
30
+ if (typeof response.data !== "string" || response.data.length === 0) {
31
+ throw new Error(`Metadata response from ${url} was empty`);
32
+ }
33
+ return response.data;
34
+ }
35
+
36
+ export async function readMetadataFromFile(filePath) {
37
+ const xml = await readFile(filePath, "utf8");
38
+ if (!xml || xml.length === 0) {
39
+ throw new Error(`Metadata file ${filePath} is empty`);
40
+ }
41
+ return xml;
42
+ }
@@ -0,0 +1,104 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+
3
+ const parser = new XMLParser({
4
+ ignoreAttributes: false,
5
+ attributeNamePrefix: "@_",
6
+ allowBooleanAttributes: true,
7
+ parseAttributeValue: false,
8
+ removeNSPrefix: true,
9
+ });
10
+
11
+ function toArray(value) {
12
+ if (value === undefined || value === null) return [];
13
+ return Array.isArray(value) ? value : [value];
14
+ }
15
+
16
+ function stripNamespace(typeRef) {
17
+ if (!typeRef) return "";
18
+ const idx = typeRef.lastIndexOf(".");
19
+ return idx >= 0 ? typeRef.substring(idx + 1) : typeRef;
20
+ }
21
+
22
+ function buildEntityTypeIndex(schema) {
23
+ const index = new Map();
24
+ for (const entityType of toArray(schema.EntityType)) {
25
+ const name = entityType["@_Name"];
26
+ if (name) index.set(name, entityType);
27
+ }
28
+ return index;
29
+ }
30
+
31
+ function extractKeyNames(entityType) {
32
+ const key = entityType.Key;
33
+ if (!key) return new Set();
34
+ return new Set(toArray(key.PropertyRef).map((ref) => ref["@_Name"]).filter(Boolean));
35
+ }
36
+
37
+ function normalizeProperty(prop, keyNames) {
38
+ const name = prop["@_Name"];
39
+ return {
40
+ name,
41
+ type: prop["@_Type"] || "Edm.String",
42
+ label: prop["@_label"] || prop["@_sap:label"] || name,
43
+ isKey: keyNames.has(name),
44
+ nullable: prop["@_Nullable"] !== "false",
45
+ maxLength: prop["@_MaxLength"],
46
+ precision: prop["@_Precision"],
47
+ scale: prop["@_Scale"],
48
+ creatable: prop["@_creatable"] !== "false",
49
+ updatable: prop["@_updatable"] !== "false",
50
+ sortable: prop["@_sortable"] !== "false",
51
+ filterable: prop["@_filterable"] !== "false",
52
+ };
53
+ }
54
+
55
+ export function parseMetadata(xml) {
56
+ const parsed = parser.parse(xml);
57
+
58
+ const edmx = parsed.Edmx;
59
+ if (!edmx) throw new Error("Could not find <edmx:Edmx> root in metadata");
60
+
61
+ const dataServices = edmx.DataServices;
62
+ if (!dataServices) throw new Error("Could not find <edmx:DataServices> in metadata");
63
+
64
+ const schemas = toArray(dataServices.Schema);
65
+ if (schemas.length === 0) throw new Error("Could not find any <Schema> in metadata");
66
+
67
+ const entitySets = [];
68
+ let namespace = "";
69
+
70
+ for (const schema of schemas) {
71
+ const schemaNs = schema["@_Namespace"] || "";
72
+ if (!namespace) namespace = schemaNs;
73
+
74
+ const entityTypeIndex = buildEntityTypeIndex(schema);
75
+
76
+ for (const container of toArray(schema.EntityContainer)) {
77
+ for (const entitySet of toArray(container.EntitySet)) {
78
+ const setName = entitySet["@_Name"];
79
+ const entityTypeRef = entitySet["@_EntityType"];
80
+ const entityTypeName = stripNamespace(entityTypeRef);
81
+ const entityType = entityTypeIndex.get(entityTypeName);
82
+
83
+ if (!entityType) continue;
84
+
85
+ const keyNames = extractKeyNames(entityType);
86
+ const properties = toArray(entityType.Property)
87
+ .map((p) => normalizeProperty(p, keyNames))
88
+ .filter((p) => p.name);
89
+
90
+ entitySets.push({
91
+ name: setName,
92
+ entityType: entityTypeName,
93
+ properties,
94
+ });
95
+ }
96
+ }
97
+ }
98
+
99
+ if (entitySets.length === 0) {
100
+ throw new Error("No EntitySets found in metadata");
101
+ }
102
+
103
+ return { namespace, entitySets };
104
+ }
@@ -0,0 +1,43 @@
1
+ import { input, confirm } from "@inquirer/prompts";
2
+
3
+ const NAMESPACE_RE = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/;
4
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
5
+
6
+ export async function runBasicPrompts() {
7
+ const appId = await input({
8
+ message: "App namespace (e.g. com.company.module.app):",
9
+ validate: (s) =>
10
+ NAMESPACE_RE.test(s.trim()) ||
11
+ "Namespace must be dotted lowercase, at least two segments (e.g. com.acme.myapp)",
12
+ });
13
+
14
+ const appTitle = await input({
15
+ message: "App title:",
16
+ default: "My UI5 App",
17
+ validate: (s) => s.trim().length > 0 || "Title cannot be empty",
18
+ });
19
+
20
+ const appDescription = await input({
21
+ message: "App description:",
22
+ default: "",
23
+ });
24
+
25
+ const minUI5Version = await input({
26
+ message: "Minimum UI5 version:",
27
+ default: "1.120.0",
28
+ validate: (s) => SEMVER_RE.test(s.trim()) || "Must be semver, e.g. 1.120.0",
29
+ });
30
+
31
+ const hasGerman = await confirm({
32
+ message: "Include German translations (i18n_de.properties) in addition to English?",
33
+ default: true,
34
+ });
35
+
36
+ return {
37
+ appId: appId.trim(),
38
+ appTitle: appTitle.trim(),
39
+ appDescription: appDescription.trim(),
40
+ minUI5Version: minUI5Version.trim(),
41
+ hasGerman,
42
+ };
43
+ }
@@ -0,0 +1,144 @@
1
+ import { select, checkbox } from "@inquirer/prompts";
2
+
3
+ const STRING_TYPES = new Set(["Edm.String", "Edm.Guid"]);
4
+
5
+ function formatPropertyChoice(prop) {
6
+ const typeLabel = prop.type.replace(/^Edm\./, "");
7
+ const keyMark = prop.isKey ? " [key]" : "";
8
+ const labelExtra = prop.label && prop.label !== prop.name ? ` — "${prop.label}"` : "";
9
+ return {
10
+ name: `${prop.name}${keyMark} (${typeLabel})${labelExtra}`,
11
+ value: prop.name,
12
+ checked: false,
13
+ };
14
+ }
15
+
16
+ export async function runFieldPrompts(metadata) {
17
+ const entitySetName = await select({
18
+ message: "Main EntitySet (the list the table will display):",
19
+ choices: metadata.entitySets.map((es) => ({
20
+ name: `${es.name} (type: ${es.entityType}, ${es.properties.length} props)`,
21
+ value: es.name,
22
+ })),
23
+ });
24
+
25
+ const entitySet = metadata.entitySets.find((es) => es.name === entitySetName);
26
+ const props = entitySet.properties;
27
+
28
+ const columns = await checkbox({
29
+ message: "Table columns (space to toggle, enter to confirm):",
30
+ choices: props.map((p) => ({
31
+ ...formatPropertyChoice(p),
32
+ checked: true,
33
+ })),
34
+ validate: (answer) => answer.length > 0 || "Pick at least one column",
35
+ });
36
+
37
+ const filterFields = await checkbox({
38
+ message: "Filter bar fields:",
39
+ choices: props
40
+ .filter((p) => p.filterable)
41
+ .map((p) => formatPropertyChoice(p)),
42
+ });
43
+
44
+ const valueHelpCandidates = metadata.entitySets.filter(
45
+ (es) => es.name !== entitySetName && es.properties.length >= 2
46
+ );
47
+
48
+ const filters = [];
49
+ for (const fieldName of filterFields) {
50
+ const prop = props.find((p) => p.name === fieldName);
51
+ const isDate = prop.type === "Edm.DateTime" || prop.type === "Edm.DateTimeOffset";
52
+ const inputType = isDate
53
+ ? "dateRange"
54
+ : await select({
55
+ message: `Filter input for "${fieldName}":`,
56
+ choices: [
57
+ { name: "Plain MultiInput (no value help)", value: "multiInput" },
58
+ { name: "MultiInput with Value Help (pick an EntitySet below)", value: "valueHelp" },
59
+ ],
60
+ });
61
+
62
+ let valueHelp = null;
63
+ if (inputType === "valueHelp") {
64
+ if (valueHelpCandidates.length === 0) {
65
+ console.log(" (No other EntitySets available for value help — falling back to plain input.)");
66
+ } else {
67
+ const vhEntitySet = await select({
68
+ message: ` Value help for "${fieldName}" — EntitySet:`,
69
+ choices: [
70
+ { name: "(none — plain input after all)", value: null },
71
+ ...valueHelpCandidates.map((es) => ({
72
+ name: `${es.name} (${es.properties.length} props)`,
73
+ value: es.name,
74
+ })),
75
+ ],
76
+ });
77
+ if (vhEntitySet) {
78
+ const vhSet = metadata.entitySets.find((es) => es.name === vhEntitySet);
79
+ const keyProp = vhSet.properties.find((p) => p.isKey) || vhSet.properties[0];
80
+ const textProp =
81
+ vhSet.properties.find((p) => !p.isKey && STRING_TYPES.has(p.type)) ||
82
+ vhSet.properties.find((p) => !p.isKey) ||
83
+ keyProp;
84
+ valueHelp = {
85
+ entitySet: vhEntitySet,
86
+ keyField: keyProp.name,
87
+ textField: textProp.name,
88
+ };
89
+ }
90
+ }
91
+ }
92
+
93
+ filters.push({
94
+ fieldName,
95
+ type: prop.type,
96
+ label: prop.label,
97
+ inputType: valueHelp ? "valueHelp" : inputType,
98
+ valueHelp,
99
+ });
100
+ }
101
+
102
+ const stringProps = props.filter((p) => STRING_TYPES.has(p.type));
103
+ const searchFields =
104
+ stringProps.length > 0
105
+ ? await checkbox({
106
+ message: "Fields included in the live search (optional — empty = no search bar):",
107
+ choices: stringProps.map((p) => formatPropertyChoice(p)),
108
+ })
109
+ : [];
110
+
111
+ const sortableProps = props.filter((p) => p.sortable);
112
+ const defaultSortField = await select({
113
+ message: "Default sort field:",
114
+ choices: [
115
+ { name: "(no default sort)", value: null },
116
+ ...sortableProps.map((p) => ({
117
+ name: `${p.name} (${p.type.replace(/^Edm\./, "")})`,
118
+ value: p.name,
119
+ })),
120
+ ],
121
+ });
122
+
123
+ let defaultSortDescending = false;
124
+ if (defaultSortField) {
125
+ defaultSortDescending = await select({
126
+ message: "Sort direction:",
127
+ choices: [
128
+ { name: "Ascending", value: false },
129
+ { name: "Descending", value: true },
130
+ ],
131
+ });
132
+ }
133
+
134
+ return {
135
+ entitySet: entitySetName,
136
+ entityType: entitySet.entityType,
137
+ keyFields: props.filter((p) => p.isKey).map((p) => p.name),
138
+ columns: columns.map((name) => props.find((p) => p.name === name)),
139
+ filters,
140
+ searchFields,
141
+ defaultSortField,
142
+ defaultSortDescending,
143
+ };
144
+ }
@@ -0,0 +1,57 @@
1
+ import { input, password, select, confirm } from "@inquirer/prompts";
2
+ import pc from "picocolors";
3
+ import { fetchMetadataFromUrl, readMetadataFromFile } from "../metadata/fetch.js";
4
+ import { parseMetadata } from "../metadata/parse.js";
5
+
6
+ export async function runOdataPrompts() {
7
+ const source = await select({
8
+ message: "OData metadata source:",
9
+ choices: [
10
+ { name: "Fetch from live service (URL + credentials)", value: "live" },
11
+ { name: "Provide local metadata.xml file", value: "file" },
12
+ ],
13
+ });
14
+
15
+ let xml;
16
+ let serviceUrl;
17
+
18
+ if (source === "live") {
19
+ serviceUrl = await input({
20
+ message: "Service URL (e.g. http://host:port/sap/opu/odata/sap/XYZ_SRV/):",
21
+ validate: (s) => /^https?:\/\//i.test(s.trim()) || "URL must start with http:// or https://",
22
+ });
23
+ const user = await input({ message: "Username:" });
24
+ const pass = await password({ message: "Password:", mask: "*" });
25
+ const insecure = await confirm({
26
+ message: "Allow self-signed / invalid SSL certificates?",
27
+ default: true,
28
+ });
29
+
30
+ console.log(pc.dim("Fetching $metadata..."));
31
+ try {
32
+ xml = await fetchMetadataFromUrl({
33
+ serviceUrl: serviceUrl.trim(),
34
+ username: user.trim(),
35
+ password: pass,
36
+ insecure,
37
+ });
38
+ } catch (err) {
39
+ throw new Error(`Failed to fetch metadata: ${err.message}`);
40
+ }
41
+ } else {
42
+ const filePath = await input({
43
+ message: "Path to metadata.xml:",
44
+ validate: (s) => s.trim().length > 0 || "Path is required",
45
+ });
46
+ xml = await readMetadataFromFile(filePath.trim());
47
+ serviceUrl = await input({
48
+ message: "Service URL to configure in manifest.json (e.g. /sap/opu/odata/sap/XYZ_SRV/):",
49
+ validate: (s) => s.trim().length > 0 || "Service URL is required",
50
+ });
51
+ }
52
+
53
+ const metadata = parseMetadata(xml);
54
+ console.log(pc.green(`Parsed ${metadata.entitySets.length} entity set(s) from namespace "${metadata.namespace}"`));
55
+
56
+ return { metadata, serviceUrl: serviceUrl.trim(), rawMetadataXml: xml };
57
+ }