@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
317 lines (282 loc) • 10.5 kB
JavaScript
const cds = require('../../..')
const { WELL_KNOWN_EVENTS } = require('../../req/event')
const FLOW_STATUS = '@flow.status'
const FROM = '@from'
const TO = '@to'
const FLOW_PREVIOUS = '$flow.previous'
const getFrom = action => {
let from = action[FROM]
return Array.isArray(from) ? from : [from]
}
function addOperationAvailableToActions(actions, statusEnum, statusElementName) {
action: for (const action of Object.values(actions)) {
const fromList = getFrom(action)
const conditions = []
for (const from of fromList) {
const value = from['#'] ? statusEnum[from['#']]?.val ?? from['#'] : from
if (typeof value !== 'string') {
const msg = `Error while constructing @Core.OperationAvailable for action "${action.name}" of "${action.parent.name}". Value of @from must either be an enum symbol or a raw string.`
cds.log('cds|edmx').warn(msg)
continue action
}
conditions.push(`$self.${statusElementName} = '${value}'`)
}
const condition = `(${conditions.join(' OR ')})`
const parsedXpr = cds.parse.expr(condition)
action['@Core.OperationAvailable'] ??= {
...parsedXpr,
['=']: condition
}
}
}
function addSideEffectToActions(actions, statusElementName) {
for (const action of Object.values(actions)) {
const properties = []
if (statusElementName.endsWith('.code')) {
const baseName = statusElementName.slice(0, -5)
properties.push(`in/${statusElementName}`)
properties.push(`in/${baseName}/*`)
properties.push(`in/${baseName}_code`)
} else {
properties.push(`in/${statusElementName}`)
}
const sideEffect = '@Common.SideEffects.TargetProperties'
if (action[sideEffect]) {
action[sideEffect].push(...properties)
} else {
action[sideEffect] = properties
}
}
}
function addActionsToTarget(targetAnnotation, entity, actions) {
const identification = (entity[targetAnnotation] ??= [])
for (const item of identification) {
if (
item.$Type === 'UI.DataFieldForAction' &&
!Object.hasOwn(item, '@UI.Hidden') &&
entity['@odata.draft.enabled'] === true
) {
item['@UI.Hidden'] = {
'=': true,
xpr: [{ ref: ['$self', 'IsActiveEntity'] }, '=', { val: false }]
}
}
}
const existingActionNames =
identification?.filter(item => item.$Type === 'UI.DataFieldForAction').map(item => item.Action.split('.').pop()) ??
[]
actions.forEach(action => {
const actionName = action.name
if (!existingActionNames.includes(actionName)) {
identification.push({
$Type: 'UI.DataFieldForAction',
Action: `${entity._service.name}.${actionName}`,
Label: action['@Common.Label'] ?? action['@title'] ?? `{i18n>${actionName}}`,
...(entity['@odata.draft.enabled'] && {
'@UI.Hidden': {
'=': true,
xpr: [{ ref: ['$self', 'IsActiveEntity'] }, '=', { val: false }]
}
})
})
}
})
}
function resolveStatusEnum(csn, codeElem) {
if (codeElem.enum !== undefined) return codeElem.enum
if (codeElem.type) {
const typeDef = csn.definitions[codeElem.type]
return typeDef ? typeDef.enum : undefined
}
}
function enhanceCSNwithFlowAnnotations4FE(csn) {
for (const definition of Object.values(csn.definitions)) {
if (definition.kind !== 'entity') continue
const entity = definition
if (!entity.elements || !entity.actions) continue
for (const [elemName, element] of Object.entries(entity.elements)) {
if (!element[FLOW_STATUS]) continue
const fromActions = []
const toActions = []
for (const action of Object.values(entity.actions)) {
if (action[FROM]) fromActions.push(action)
if (action[TO]) toActions.push(action)
}
if (fromActions.length === 0 && toActions.length === 0) continue
addActionsToTarget('@UI.Identification', entity, toActions)
addActionsToTarget('@UI.LineItem', entity, toActions)
if (element.enum) {
// Element is an enum directly
addSideEffectToActions(toActions, elemName)
addOperationAvailableToActions(fromActions, element.enum, elemName)
} else if (element.target) {
// Element is an association to a codelist
const targetDef = csn.definitions[element.target]
if (targetDef?.elements?.code) {
const codeElem = targetDef.elements.code
const statusEnum = resolveStatusEnum(csn, codeElem)
if (statusEnum) {
// REVISIT: is there no way to know from the CSN?
const statusElementName = csn._4java ? elemName + '.code' : elemName + '_code'
addSideEffectToActions(toActions, statusElementName)
addOperationAvailableToActions(fromActions, statusEnum, statusElementName)
}
}
} else if (element['@odata.foreignKey4']) {
// when compiling to edmx, the foreign key is also annotated with @flow.status, but has no info about the target
continue
} else {
cds.error(
`Status element in entity ${entity.name} is not an enum and does not have a valid target with code enum.`
)
}
}
}
}
module.exports = function cds_compile_for_flows(csn) {
const { history_for_flows } = cds.env.features
const _requires_history = !history_for_flows
? () => false
: history_for_flows === 'all'
? def => {
for (const each in def.elements) {
if (def.elements[each]['@flow.status']) {
return true
}
}
}
: def => {
for (const each in def.actions) {
const action = def.actions[each]
if (action && action[TO]?.['='] === FLOW_PREVIOUS) {
return true
}
}
}
/*
* 1. propagate flows for well-known actions from extensions to definitions
*/
if (csn.extensions) {
for (const ext of csn.extensions) {
if (!ext.actions) continue
const def = csn.definitions[ext.annotate]
if (!def || !def.kind || def.kind !== 'entity') continue
for (const each in ext.actions) {
if (!(each in WELL_KNOWN_EVENTS)) continue
def.actions ??= {}
def.actions[each] ??= { kind: 'action' }
Object.assign(def.actions[each], ext.actions[each])
}
}
}
const to_be_extended = new Set()
for (const name in csn.definitions) {
const def = csn.definitions[name]
/*
* 2. propagate @flow.status to respective element and make it @readonly
*/
if (def['@flow.status']?.['=']) {
const element = def.elements?.[def['@flow.status']['=']]
if (element) {
element['@flow.status'] = true
if (!('@readonly' in element)) element['@readonly'] = true
}
}
if (!def.kind || def.kind !== 'entity' || !def.actions) continue
/*
* 3. normalize @from and @to annotations
*/
for (const each in def.actions) {
const action = def.actions[each]
if (action['@flow.from']) action['@from'] = action['@flow.from']
if (action['@flow.to']) action['@to'] = action['@flow.to']
}
/*
* 4. automatically apply aspect FlowHistory if needed and not present yet
*/
if (!_requires_history(def)) continue
const projections = _get_projection_stack(name, csn)
const base_name = projections.pop()
const base = csn.definitions[base_name]
if (base.elements?.transitions_) continue //> manually added -> don't interfere
// add aspect FlowHistory to db entity
to_be_extended.add(base_name)
}
if (to_be_extended.size) {
// REVISIT: ensure sap.common.FlowHistory is there
csn.definitions['sap.common.FlowHistory'] ??= JSON.parse(FlowHistory)
const extensions = [...to_be_extended].map(extend => ({ extend, includes: ['sap.common.FlowHistory'] }))
const dsn = cds.extend(csn).with({ extensions })
// REVISIT: annotate all generated X.transitions_ with @cds.autoexpose: false
for (const each of to_be_extended) dsn.definitions[`${each}.transitions_`]['@cds.autoexpose'] = false
// hack for "excludes" not possible via extensions
for (const name in dsn.definitions) {
const _new = dsn.definitions[name]
if (
_new.kind !== 'entity' ||
to_be_extended.has(name.replace(/\.transitions_$/, '')) ||
(!name.match(/\.transitions_$/) && !_new.elements.transitions_)
) {
continue
}
const _old = csn.definitions[name]
if (!_old) delete dsn.definitions[name]
else if (_new.elements.transitions_ && !_old.elements.transitions_) delete _new.elements.transitions_
}
csn = dsn
}
// REVISIT: annotate all X.transitions_ with @odata.draft.enabled: false
for (const name in csn.definitions)
if (name.endsWith('.transitions_')) csn.definitions[name]['@odata.draft.enabled'] = false
return csn
}
function _get_projection_stack(name, csn, stack = []) {
stack.push(name)
const def = csn.definitions[name]
if (def.projection || def.query) {
const base = (def.projection || def.query.SELECT)?.from?.ref?.[0]
if (!base) throw new Error(`Unable to determine base entity of ${name}`)
return _get_projection_stack(base, csn, stack)
}
return stack
}
const FlowHistory = `{
"kind": "aspect",
"@cds.persistence.skip": "if-unused",
"elements": {
"transitions_": {
"@odata.draft.enabled": false,
"type": "cds.Composition",
"cardinality": { "max": "*" },
"targetAspect": {
"elements": {
"timestamp": {
"@cds.on.insert": { "=": "$now" },
"@UI.HiddenFilter": true,
"@UI.ExcludeFromNavigationContext": true,
"@Core.Immutable": true,
"@title": "{i18n>CreatedAt}",
"@readonly": true,
"key": true,
"type": "cds.Timestamp"
},
"user": {
"@cds.on.insert": { "=": "$user" },
"@UI.HiddenFilter": true,
"@UI.ExcludeFromNavigationContext": true,
"@Core.Immutable": true,
"@title": "{i18n>CreatedBy}",
"@readonly": true,
"@description": "{i18n>UserID.Description}",
"type": "cds.String",
"length": 255
},
"status": { "type": "cds.String" },
"comment": { "type": "cds.String" }
}
}
}
}
}`
module.exports.enhanceCSNwithFlowAnnotations4FE = enhanceCSNwithFlowAnnotations4FE
module.exports.getFrom = getFrom