@sap/cds-fiori
Version:
SAP Cloud Application Programming Model - Services for SAP Fiori Elements
361 lines (338 loc) • 12 kB
JavaScript
const cds = require('@sap/cds')
cds.on('served', () => {
const { fiori } = cds.env; if (!fiori.preview) return
const providers = cds.service.providers
const mountPoint = '/$fiori-preview'
const appID = 'preview-app'
const _appURL = (srv, entity) => `${mountPoint}/${srv}/${entity}#${appID}`
const _componentURL = (srv, entity) => `${mountPoint}/${srv}/${entity}/app`
const isODataEndpoint = endPoint => endPoint.kind.startsWith('odata')
const findODataEndpoint = srv => srv.endpoints?.find(isODataEndpoint)
function _manifest(serviceName, entityName) {
const [serviceProv, serviceInfo] = _validate(serviceName, entityName)
const endpoint = findODataEndpoint(serviceProv)
const listPageInitialLoad = fiori.preview.initialload ?? true
const manifest = {
_version: '1.8.0',
'sap.app': {
id: 'preview',
type: 'application',
title: `Preview ‒ List of ${serviceProv.name}.${entityName}`,
description: 'Preview Application',
dataSources: {
mainService: {
uri: `${endpoint.path}/`,
type: 'OData',
settings: {
odataVersion: '4.0'
}
}
},
},
'sap.ui5': {
flexEnabled: true,
dependencies: {
minUI5Version: '1.96.0',
libs: {
'sap.ui.core': {},
'sap.fe.templates': {}
}
},
models: {
'': {
dataSource: 'mainService',
preload: true,
settings: {
synchronizationMode: 'None',
operationMode: 'Server',
autoExpandSelect: true,
earlyRequests: true,
groupId: '$direct'
}
}
},
routing: {
routes: [
{
name: `${entityName}ListRoute`,
target: `${entityName}ListTarget`,
pattern: ':?query:',
},
{
name: `${entityName}DetailsRoute`,
target: `${entityName}DetailsTarget`,
pattern: `${entityName}({key}):?query:`,
}
],
targets: {
[`${entityName}ListTarget`]: {
type: 'Component',
id: `${entityName}ListTarget`,
name: 'sap.fe.templates.ListReport',
options: {
settings: {
contextPath: `/${entityName}`,
initialLoad: listPageInitialLoad,
navigation: {
[`${entityName}`]: {
detail: {
route: `${entityName}DetailsRoute`
}
}
}
}
}
},
[`${entityName}DetailsTarget`]: {
type: 'Component',
id: `${entityName}DetailsTarget`,
name: 'sap.fe.templates.ObjectPage',
options: {
settings: {
contextPath: `/${entityName}`,
navigation: {}
}
}
}
}
},
},
contentDensities: {
compact: true,
cozy: true
},
'sap.ui': {
technology: 'UI5',
fullWidth: true,
deviceTypes: {
desktop: true,
tablet: true,
phone: true
}
},
'sap.fiori': {
registrationIds: [],
archeType: 'transactional'
},
}
const { routing } = manifest['sap.ui5']
for (const { navProperty } of serviceInfo) {
// add a route for the navigation property
routing.routes.push(
{
name: `${navProperty}Route`,
target: `${navProperty}Target`,
pattern: `${entityName}({key})/${navProperty}({key2}):?query:`,
}
)
// add a route target leading to the target entity
routing.targets[`${navProperty}Target`] = {
type: 'Component',
id: `${navProperty}Target`,
name: 'sap.fe.templates.ObjectPage',
options: {
settings: {
contextPath: '/' + entityName + '/' + navProperty
}
}
}
// wire the new route from the source entity's navigation (see above)
routing.targets[`${entityName}DetailsTarget`].options.settings.navigation[navProperty] = {
detail: {
route: `${navProperty}Route`
}
}
}
return manifest
}
function _html(serviceName, entityName) {
_validate(serviceName, entityName)
let ui5Version = fiori.preview.ui5?.version || cds.env.preview?.fiori?.ui5?.version || ''
let ui5Host = fiori.preview.ui5?.host || `https://sapui5.hana.ondemand.com/${ui5Version}`
if (!ui5Host.endsWith('/')) ui5Host += '/'
const theme = fiori.preview.ui5?.theme || { light: 'sap_horizon', dark: 'sap_horizon_dark', switch: true }
// copied from UI5's test-resources/sap/ushell/shells/sandbox/fioriSandbox.html
return `
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Preview for ${serviceName}.${entityName}</title>
<script>
window["sap-ushell-config"] = {
defaultRenderer: "fiori2",
bootstrapPlugins: {
RuntimeAuthoringPlugin: {
component: "sap.ushell.plugins.rta",
config: {
validateAppVersion: false
}
}
},
services: {
EndUserFeedback: {
config: {
enabled: false,
},
},
},
renderers: {
fiori2: {
componentData: {
config: {
enableNotificationsUI: false,
enableNotifications: false,
enableSearch: false,
enablePersonalization: false,
enableSetTheme: true,
enableSetLanguage: true,
}
}
}
},
applications: {
"${appID}": {
title: "Browse ${entityName}",
description: "from ${serviceName}",
additionalInformation: "SAPUI5.Component=preview",
applicationType : "URL",
url: "${_componentURL(serviceName, entityName)}",
navigationMode: "embedded"
}
}
}
</script>
<script id="sap-ushell-bootstrap" src="${ui5Host}test-resources/sap/ushell/bootstrap/sandbox.js"></script>
<script id="sap-ui-bootstrap" src="${ui5Host}resources/sap-ui-core.js"
data-sap-ui-flexibilityServices='[{"connector": "LocalStorageConnector"}]'
data-sap-ui-libs="sap.ui.core, sap.ui.generic.app, sap.ushell, sap.fe.templates"
data-sap-ui-oninit="module:sap/ui/core/ComponentSupport"
data-sap-ui-compatVersion="edge"
data-sap-ui-theme="sap_horizon"
data-sap-ui-async="true"
data-sap-ui-frameOptions="allow"
data-sap-ui-preload="async"></script>
<script>
function setTheme(dark) { sap.ui.getCore().applyTheme(dark ? "${theme.dark}" : "${theme.light}"); }
const colorMatcher = window.matchMedia('(prefers-color-scheme: dark)')
setTheme(colorMatcher.matches)
${theme.switch} && colorMatcher.addEventListener("change", e => setTheme(e.matches));
sap.ui.getCore().attachInit(function() { sap.ushell.Container.createRenderer().placeAt("content") })
</script>
</head>
<body class="sapUiBody sapUiSizeCompact" id="content"></body>
</html>
`
}
function _componentJs(serviceName, entityName) {
return `sap.ui.define(["sap/fe/core/AppComponent"], function(AppComponent) {
"use strict";
return AppComponent.extend("preview.Component", {
metadata: {manifest: 'json'}
});
});`
}
function _componentPreload(serviceName, entityName) {
return `//@ui5-bundle preview/Component-preload.js
jQuery.sap.registerPreloadedModules({
"version":"2.0",
"modules":{
"preview/Component.js": function(){${_componentJs(serviceName, entityName)}
},
"preview/manifest.json":'${JSON.stringify(_manifest(serviceName, entityName))}'
}});`
}
function _appConfig() {
return `{
"bootstrapPlugins": {},
"services": {
"LaunchPage": {
"adapter": {
"config": {
"catalogs": [],
"groups": [
{
"id": "Home",
"title": "Home",
"isPreset": true,
"isVisible": true,
"isGroupLocked": false,
"tiles": []
}
]
}
}
},
"NavTargetResolution": {
"config": {
"enableClientSideTargetResolution": true
}
},
"ClientSideTargetResolution": {
"adapter": {
"config": {
"inbounds": {}
}
}
}
}
}`
}
function _validate(serviceName, entityName) {
const serviceProv = providers.find(s => s.name === serviceName)
if (!serviceProv) throw _badRequest(`No such service '${serviceName}'. Available: [${providers.map(p => p.name)}]`)
const odata = findODataEndpoint(serviceProv)
if (!odata) throw _badRequest(`Not an OData service: ${serviceName}`)
return _serviceInfo(serviceProv, entityName)
}
function _serviceInfo(serviceProv, entityName) {
const entities = serviceProv.model.entities(serviceProv.name)
const entity = entities[entityName]
if (!entity) throw _badRequest(`No such entity '${entityName}' in service '${serviceProv.name}'`)
return [serviceProv, serviceProv.model.all('Association', entity.elements)
.filter(a =>
!a.target.endsWith('.texts') &&
!a.target.endsWith('_texts') &&
!a.target.endsWith('DraftAdministrativeData') &&
a.name !== 'SiblingEntity')
.map(a => { return { navProperty: a.name, targetEntity: a.target.split('.')[1] } })
]
}
const _badRequest = (message) => { const err = new Error(message); err.statusCode = 400; return err }
// fetch and instrument all OData providers
const any = providers.filter(srv => !!findODataEndpoint(srv))
.map(srv => {
// called from ../index.js to provide the data for the HTML link
const link = linkProvider(srv)
srv.$linkProviders ? srv.$linkProviders.push(link) : srv.$linkProviders = [link]
return link
})
.length
// install middlewares once
if (any) {
const router = require('express').Router()
// UI5 component
router.get('/:service/:entity/app/Component-preload.js', ({ params }, resp) => resp.send(_componentPreload(params.service, params.entity)))
router.get('/:service/:entity/app/Component.js', ({ params }, resp) => resp.send(_componentJs(params.service, params.entity)))
router.get('/:service/:entity/app/manifest.json', ({ params }, resp) => resp.send(_manifest(params.service, params.entity)))
router.get('/appconfig/fioriSandboxConfig.json', ({ }, resp) => resp.json(_appConfig()))
// html
router.get('/:service/:entity', ({ params }, resp, next) => {
if (params.entity === 'fioriSandboxConfig.json') return next() // Fiori sends this, skip over it
resp.send(_html(params.service, params.entity))
})
cds.app.use(mountPoint.replace('$', '\\$'), router)
}
function linkProvider(service) {
return (entity, endpoint) => {
if (!entity || (endpoint && !isODataEndpoint(endpoint))) return
return {
href: _appURL(service.name, entity),
title: 'Preview in Fiori elements',
name: 'Fiori preview'
}
}
}
})