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.
- package/README.md +141 -0
- package/package.json +34 -0
- package/src/cli.js +74 -0
- package/src/metadata/fetch.js +42 -0
- package/src/metadata/parse.js +104 -0
- package/src/prompts/basic.js +43 -0
- package/src/prompts/fields.js +144 -0
- package/src/prompts/odata.js +57 -0
- package/src/render/buildContext.js +67 -0
- package/src/render/controlForType.js +136 -0
- package/src/render/engine.js +81 -0
- package/src/templates/README.md.ejs +62 -0
- package/src/templates/_valueHelp.fragment.xml.ejs +17 -0
- package/src/templates/package.json.ejs +14 -0
- package/src/templates/ui5.yaml.ejs +28 -0
- package/src/templates/webapp/Component.js.ejs +47 -0
- package/src/templates/webapp/controller/App.controller.js +29 -0
- package/src/templates/webapp/controller/BaseController.js +35 -0
- package/src/templates/webapp/controller/ErrorHandler.js +71 -0
- package/src/templates/webapp/controller/NotFound.controller.js +12 -0
- package/src/templates/webapp/controller/Worklist.controller.js.ejs +1158 -0
- package/src/templates/webapp/css/style.css +17 -0
- package/src/templates/webapp/i18n/i18n.properties.ejs +83 -0
- package/src/templates/webapp/i18n/i18n_de.properties.ejs +83 -0
- package/src/templates/webapp/index.html.ejs +52 -0
- package/src/templates/webapp/localService/backendCheck.js.ejs +52 -0
- package/src/templates/webapp/localService/mockserver.js.ejs +29 -0
- package/src/templates/webapp/manifest.json.ejs +106 -0
- package/src/templates/webapp/model/formatter.js +148 -0
- package/src/templates/webapp/model/models.js +23 -0
- package/src/templates/webapp/view/App.view.xml +10 -0
- package/src/templates/webapp/view/NotFound.view.xml +12 -0
- package/src/templates/webapp/view/Worklist.view.xml.ejs +173 -0
- package/src/templates/webapp/view/fragments/GroupDialog.fragment.xml.ejs +11 -0
- package/src/templates/webapp/view/fragments/InfoPopover.fragment.xml +38 -0
- 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
|
+
}
|