cap-domain 3.0.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/.gitattributes +2 -0
- package/README.md +239 -0
- package/cds-plugin.js +5 -0
- package/index.js +33 -0
- package/package.json +25 -0
- package/src/FieldControls.js +356 -0
- package/src/Handlers.js +273 -0
- package/src/ServiceParser.js +56 -0
- package/src/SymbolHelper.js +125 -0
- package/src/Utils.js +61 -0
- package/src/cdsPluginImpl.js +22 -0
- package/src/defaultAnnotationValues.js +44 -0
- package/tests/.cdsrc.json +1 -0
- package/tests/.vscode/extensions.json +18 -0
- package/tests/.vscode/launch.json +18 -0
- package/tests/.vscode/settings.json +11 -0
- package/tests/.vscode/tasks.json +25 -0
- package/tests/_i18n/i18n.properties +5 -0
- package/tests/db/data/my.bookshop-Authors.csv +3 -0
- package/tests/db/data/my.bookshop-Books.csv +3 -0
- package/tests/db/data/my.bookshop-Details.csv +3 -0
- package/tests/db/data/my.bookshop-Request.csv +3 -0
- package/tests/db/data-model.cds +37 -0
- package/tests/eslint.config.js +6 -0
- package/tests/jest.config.js +9 -0
- package/tests/package.json +35 -0
- package/tests/srv/@FCDefinitions/Authors.js +24 -0
- package/tests/srv/@FCDefinitions/Books.js +23 -0
- package/tests/srv/@FCDefinitions/Details.js +16 -0
- package/tests/srv/@FCDefinitions/Request.js +22 -0
- package/tests/srv/CatalogService-annotations.cds +44 -0
- package/tests/srv/CatalogService.cds +46 -0
- package/tests/srv/CatalogService.js +21 -0
- package/tests/srv/_i18n/i18n_de.properties +6 -0
- package/tests/srv/_i18n/i18n_en.properties +6 -0
- package/tests/srv/cat-fiori-service.cds +7 -0
- package/tests/test/UsersApi.js +97 -0
- package/tests/test/authors.test.js +26 -0
- package/tests/test/books.test.js +97 -0
- package/tests/test/request.test.js +39 -0
- package/tests/test/setup.js +54 -0
package/.gitattributes
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# cap-domain - SAP CAP Field Control Plugin
|
|
2
|
+
|
|
3
|
+
A SAP CAP plugin that provides dynamic field control: it calculates OData `Common.FieldControl`
|
|
4
|
+
values (Mandatory / Optional / ReadOnly / Hidden) at runtime and enforces matching server-side
|
|
5
|
+
validation, so the client (e.g. Fiori Elements) and the backend always agree on what a field's
|
|
6
|
+
current state allows.
|
|
7
|
+
|
|
8
|
+
Requires `@sap/cds` >= 10.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install cap-domain
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
### 1. Enable the plugin
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"cds": {
|
|
23
|
+
"enable:capdomain:plugin": true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Provide the required i18n texts
|
|
29
|
+
|
|
30
|
+
The library reports read-only and mandatory violations using its own i18n keys. Add these to
|
|
31
|
+
your app's i18n bundle (e.g. `_i18n/i18n.properties`) - otherwise the raw key is shown instead
|
|
32
|
+
of a readable message:
|
|
33
|
+
|
|
34
|
+
```properties
|
|
35
|
+
capdomain.validation.message.readOnly={0} is Read-only field
|
|
36
|
+
capdomain.validation.message.required={0} is Required field
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 3. Create a field control configuration
|
|
40
|
+
|
|
41
|
+
Create `srv/@FCDefinitions/MyEntity.js`:
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
const { fieldControlDictionary } = require('cap-domain');
|
|
45
|
+
|
|
46
|
+
const fieldControlConfigurations = {
|
|
47
|
+
mandatory: {
|
|
48
|
+
fc: () => fieldControlDictionary.Mandatory
|
|
49
|
+
},
|
|
50
|
+
conditional: {
|
|
51
|
+
fc: (entity) => entity.condition ?
|
|
52
|
+
fieldControlDictionary.Mandatory :
|
|
53
|
+
fieldControlDictionary.Hidden
|
|
54
|
+
},
|
|
55
|
+
validated: {
|
|
56
|
+
fc: () => fieldControlDictionary.Mandatory,
|
|
57
|
+
validator: (value, { i18n }) => {
|
|
58
|
+
if (!value || value.length < 5) {
|
|
59
|
+
return i18n.getText('validation.minLength', [5]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
module.exports = fieldControlConfigurations;
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Each key must match the *short* name of a virtual `<field>_fc` element declared on the service
|
|
69
|
+
entity (see step 4), i.e. `mandatory` configures `mandatory_fc`.
|
|
70
|
+
|
|
71
|
+
### 4. Configure the CDS service
|
|
72
|
+
|
|
73
|
+
```cds
|
|
74
|
+
service MyService {
|
|
75
|
+
@FCSettings: {
|
|
76
|
+
path: 'srv/@FCDefinitions/MyEntity.js'
|
|
77
|
+
}
|
|
78
|
+
entity MyEntity as projection on my.MyEntity {
|
|
79
|
+
*,
|
|
80
|
+
virtual null as mandatory_fc : Integer @odata.Type: 'Edm.Byte',
|
|
81
|
+
virtual null as conditional_fc : Integer @odata.Type: 'Edm.Byte',
|
|
82
|
+
virtual null as validated_fc : Integer @odata.Type: 'Edm.Byte'
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
annotate MyService.MyEntity with {
|
|
87
|
+
fieldName @(Common.FieldControl: {$value: conditional_fc});
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
> Every field that clients are allowed to change must carry its own `@Common.FieldControl`
|
|
92
|
+
> annotation. By default (`enable:capdomain:blockUnannotatedValueChanges`), updates to any
|
|
93
|
+
> unannotated field are silently dropped - see [Configuration](#configuration).
|
|
94
|
+
|
|
95
|
+
### 5. Wire up the handlers
|
|
96
|
+
|
|
97
|
+
Create `srv/my-service.js`:
|
|
98
|
+
|
|
99
|
+
```javascript
|
|
100
|
+
const {
|
|
101
|
+
execAfterREADHandler,
|
|
102
|
+
execUPDATEHandler,
|
|
103
|
+
bindEntityHandlers
|
|
104
|
+
} = require('cap-domain');
|
|
105
|
+
|
|
106
|
+
module.exports = (srv) => {
|
|
107
|
+
const { MyEntity } = srv.entities;
|
|
108
|
+
|
|
109
|
+
// Basic handlers
|
|
110
|
+
srv.after('READ', MyEntity, execAfterREADHandler);
|
|
111
|
+
srv.on('UPDATE', MyEntity, execUPDATEHandler);
|
|
112
|
+
|
|
113
|
+
// Draft-enabled entity handlers
|
|
114
|
+
if (MyEntity['@odata.draft.enabled']) {
|
|
115
|
+
const { DRAFTPrepareHandler, CreateDraftHandler } = bindEntityHandlers(MyEntity);
|
|
116
|
+
|
|
117
|
+
srv.on('NEW', MyEntity.drafts, CreateDraftHandler);
|
|
118
|
+
srv.after('READ', MyEntity.drafts, execAfterREADHandler);
|
|
119
|
+
srv.on('UPDATE', MyEntity.drafts, execUPDATEHandler);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For `CREATE`, validate explicitly with `validateAndThowErrorsIfExists` (see below) since CAP has
|
|
125
|
+
no generic hook equivalent to `execUPDATEHandler` for creates.
|
|
126
|
+
|
|
127
|
+
## Configuration
|
|
128
|
+
|
|
129
|
+
### Environment Variables
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"cds": {
|
|
134
|
+
"enable:capdomain:plugin": true,
|
|
135
|
+
"enable:capdomain:liveValidations": true,
|
|
136
|
+
"enable:capdomain:autoErase": true,
|
|
137
|
+
"enable:capdomain:defaultFCValue": 3,
|
|
138
|
+
"enable:capdomain:blockUnannotatedValueChanges": true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
| Variable | Default | Effect |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `enable:capdomain:plugin` | `false` | Activates the CDS plugin that wires field control metadata onto annotated entities. |
|
|
146
|
+
| `enable:capdomain:liveValidations` | `true` | For a request with no persisted record yet (`CREATE`), validates the whole entity using empty defaults for any field missing from the payload, so mandatory checks fire progressively as a form is filled in. For an existing record (`UPDATE`), untouched fields always keep their persisted values regardless of this setting. |
|
|
147
|
+
| `enable:capdomain:autoErase` | `true` | Nulls out a field's value as soon as its field control becomes ReadOnly/Hidden. |
|
|
148
|
+
| `enable:capdomain:defaultFCValue` | `3` (Optional) | Fallback field control value used when a field has no explicit calculator. |
|
|
149
|
+
| `enable:capdomain:blockUnannotatedValueChanges` | `true` | Drops any incoming field that has no `@Common.FieldControl` annotation of its own. This is a strict, secure-by-default setting: every editable field - including plain "trigger" fields that only influence *other* fields' field control - must be annotated. |
|
|
150
|
+
|
|
151
|
+
Per-entity `@FCSettings` annotations (`path`, `liveValidations`, `autoErase`,
|
|
152
|
+
`blockUnannotatedValueChanges`, `useImpl`) override these environment defaults for that entity.
|
|
153
|
+
|
|
154
|
+
### Core Functions
|
|
155
|
+
|
|
156
|
+
#### `validateAndThowErrorsIfExists(req, dataForValidation, targetPrefix)`
|
|
157
|
+
Validates data and throws errors if validation fails. Typical use in a `CREATE` handler, where
|
|
158
|
+
there is no generic update hook to bind to:
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
const { validateAndThowErrorsIfExists } = require('cap-domain');
|
|
162
|
+
|
|
163
|
+
srv.on('CREATE', MyEntity, async (req, next) => {
|
|
164
|
+
await validateAndThowErrorsIfExists(req, req.data, 'in');
|
|
165
|
+
return await next();
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
#### `validateWithFCs(req, dataForValidation)`
|
|
170
|
+
Validates data and returns validation errors.
|
|
171
|
+
|
|
172
|
+
```javascript
|
|
173
|
+
const { validateWithFCs } = require('cap-domain');
|
|
174
|
+
|
|
175
|
+
const errors = await validateWithFCs(req, req.data, { csnEntity, context = {} });
|
|
176
|
+
|
|
177
|
+
validateAndAddMyMessage(errors);
|
|
178
|
+
|
|
179
|
+
if (errors.length > 0) {
|
|
180
|
+
errors.forEach(error => {
|
|
181
|
+
req.error({
|
|
182
|
+
target: `/MyEntity(${req.params.at(0).ID})/${error.fieldName}`,
|
|
183
|
+
message: error.message,
|
|
184
|
+
code: 400
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
req.reject();
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
#### `calculateFieldControls(data, req, { csnEntity, context = {} })`
|
|
192
|
+
Calculates field control values for entities.
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
const { calculateFieldControls } = require('cap-domain');
|
|
196
|
+
|
|
197
|
+
const entityWithFCs = await calculateFieldControls(entity, req, {
|
|
198
|
+
csnEntity: MyEntity, // optional, req.target will be used by default
|
|
199
|
+
context: { additionalData: 'value' }
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
#### `execAfterREADHandler(entity, req, context)`
|
|
204
|
+
Executes READ handler for field control calculation.
|
|
205
|
+
|
|
206
|
+
```javascript
|
|
207
|
+
const { execAfterREADHandler } = require('cap-domain');
|
|
208
|
+
|
|
209
|
+
srv.after('READ', MyEntity, execAfterREADHandler);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### `execUPDATEHandler(req, next, context)`
|
|
213
|
+
Executes UPDATE handler with field control validation.
|
|
214
|
+
|
|
215
|
+
```javascript
|
|
216
|
+
const { execUPDATEHandler } = require('cap-domain');
|
|
217
|
+
|
|
218
|
+
srv.on('UPDATE', MyEntity, execUPDATEHandler);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
#### `Utils`
|
|
223
|
+
```javascript
|
|
224
|
+
const { Utils } = require('cap-domain');
|
|
225
|
+
|
|
226
|
+
const message = Utils.getText('validation.required', ['dynamic field value']);
|
|
227
|
+
const entityName = Utils.getEntityName(csnEntity);
|
|
228
|
+
const i18n = Utils.getBoundI18nBundle();
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Example project
|
|
232
|
+
|
|
233
|
+
See [`tests/`](./tests) for a runnable CAP showcase (bookshop-style model) exercising mandatory
|
|
234
|
+
fields, conditional field control, read-only enforcement, auto-erase and the
|
|
235
|
+
`blockUnannotatedValueChanges` guard:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
npm test
|
|
239
|
+
```
|
package/cds-plugin.js
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// https://cap.cloud.sap/docs/node.js/fiori
|
|
2
|
+
|
|
3
|
+
const { fieldControlDictionary, handlers } = require('./src/FieldControls');
|
|
4
|
+
const cdsPluginImpl = require('./src/cdsPluginImpl');
|
|
5
|
+
const Utils = require('./src/Utils');
|
|
6
|
+
const {
|
|
7
|
+
execAfterREADHandler,
|
|
8
|
+
execUPDATEHandler,
|
|
9
|
+
validateWithFCs,
|
|
10
|
+
provideErrors,
|
|
11
|
+
throwErrorsAndStopIfExists,
|
|
12
|
+
validateAndThowErrorsIfExists,
|
|
13
|
+
validateAndAttachErrors,
|
|
14
|
+
calculateFieldControls,
|
|
15
|
+
bindEntityHandlers
|
|
16
|
+
} = require('./src/Handlers');
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
Utils,
|
|
20
|
+
fieldControlDictionary,
|
|
21
|
+
init: cdsPluginImpl,
|
|
22
|
+
...handlers,
|
|
23
|
+
|
|
24
|
+
bindEntityHandlers,
|
|
25
|
+
calculateFieldControls,
|
|
26
|
+
execAfterREADHandler,
|
|
27
|
+
execUPDATEHandler,
|
|
28
|
+
validateWithFCs,
|
|
29
|
+
provideErrors,
|
|
30
|
+
throwErrorsAndStopIfExists,
|
|
31
|
+
validateAndThowErrorsIfExists,
|
|
32
|
+
validateAndAttachErrors
|
|
33
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cap-domain",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "SAP CAP Field Control Plugin - dynamic field visibility, editability and validation",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "cd tests && npx jest --silent=true"
|
|
8
|
+
},
|
|
9
|
+
"license": "ISC",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/yauhenshalik-hub/cap-domain.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/yauhenshalik-hub/cap-domain/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/yauhenshalik-hub/cap-domain#readme",
|
|
18
|
+
"dependencies": {},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@sap/cds": ">=10"
|
|
21
|
+
},
|
|
22
|
+
"workspaces": [
|
|
23
|
+
"tests"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
const Utils = require('./Utils');
|
|
2
|
+
const { getOnBeforeCalculateFC, getOnBeforeSave, getOnAfterSave, setOnBeforeSave, setOnBeforeCalculateFC, setOnAfterSave, getSrvEntitiesFCs } = require('./SymbolHelper');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* https://sap.github.io/odata-vocabularies/vocabularies/Common.html#FieldControlType
|
|
6
|
+
* Field control value constants
|
|
7
|
+
* These values determine the visibility and editability of fields
|
|
8
|
+
*/
|
|
9
|
+
const fieldControlDictionary = {
|
|
10
|
+
|
|
11
|
+
// Visible, Editable, Required
|
|
12
|
+
Mandatory: 7,
|
|
13
|
+
|
|
14
|
+
// Visible, Editable, Not Required
|
|
15
|
+
Optional: 3,
|
|
16
|
+
|
|
17
|
+
// Visible, Not Editable
|
|
18
|
+
ReadOnly: 1,
|
|
19
|
+
|
|
20
|
+
// Not Visible
|
|
21
|
+
Hidden: 0
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const defaultEnvFC = Number(cds.env['enable:capdomain:defaultFCValue'] ?? fieldControlDictionary.Optional);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Calculate field control for a specific field.
|
|
28
|
+
* @param {Function} calculator Function to calculate field control value.
|
|
29
|
+
* @param {object} entity The entity data.
|
|
30
|
+
* @param {object} helperContext Context object for calculation.
|
|
31
|
+
* @returns {number|boolean} Field Control calculated value
|
|
32
|
+
*/
|
|
33
|
+
function calculateFieldControl(calculator, entity, helperContext) {
|
|
34
|
+
if (!calculator) {
|
|
35
|
+
return fieldControlDictionary.ReadOnly;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return calculator(entity, helperContext);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get field control values from data and CSN definition
|
|
43
|
+
* @param {object} data The data object
|
|
44
|
+
* @param {object} csnEntityDefinition The CSN entity definition
|
|
45
|
+
* @param {object} i18n The i18n bundle
|
|
46
|
+
* @returns {object} Field Control values
|
|
47
|
+
*/
|
|
48
|
+
function getFieldControlValues(data, csnEntityDefinition, i18n) {
|
|
49
|
+
return Object.entries(csnEntityDefinition.elements).reduce(
|
|
50
|
+
(acc, [ key, cdsDefinition ]) => {
|
|
51
|
+
const fieldControlAnnotation = cdsDefinition['@Common.FieldControl'];
|
|
52
|
+
const label = cdsDefinition['@Common.Label'];
|
|
53
|
+
|
|
54
|
+
if (fieldControlAnnotation) {
|
|
55
|
+
const isMandatory = cdsDefinition['@mandatory'];
|
|
56
|
+
const fieldControlValuePath = fieldControlAnnotation['='];
|
|
57
|
+
const fcValue =
|
|
58
|
+
data[fieldControlValuePath] ??
|
|
59
|
+
((isMandatory && fieldControlDictionary.Mandatory) ||
|
|
60
|
+
defaultEnvFC);
|
|
61
|
+
|
|
62
|
+
acc[key] = {
|
|
63
|
+
fcShortPath: fieldControlValuePath?.replace('_fc', ''),
|
|
64
|
+
label: label ? i18n.getText(label.replace('{i18n>', '').replace('}', '')) : '',
|
|
65
|
+
fcValue
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return acc;
|
|
70
|
+
},
|
|
71
|
+
{}
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get mapping of entity field control annotations.
|
|
77
|
+
* @param {object} entityDefinitionElements The entity definition elements.
|
|
78
|
+
* @returns {object} Fields mapping
|
|
79
|
+
*/
|
|
80
|
+
function getEntityFCAnnotationsMapping(entityDefinitionElements) {
|
|
81
|
+
const { elements } = entityDefinitionElements;
|
|
82
|
+
const settings = Object.entries(elements).reduce((acc, [ fieldName, element ]) => {
|
|
83
|
+
const elementFC = element['@Common.FieldControl'];
|
|
84
|
+
const { '=': fieldControlBindingPath } = elementFC || {};
|
|
85
|
+
|
|
86
|
+
if (fieldControlBindingPath) {
|
|
87
|
+
acc[fieldName] = { fieldName, FCPath: fieldControlBindingPath };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return acc;
|
|
91
|
+
}, {});
|
|
92
|
+
|
|
93
|
+
return settings;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* FieldControls class to handle the logic for field control values
|
|
98
|
+
* based on different conditions in the request or entity data
|
|
99
|
+
*/
|
|
100
|
+
class FieldControls {
|
|
101
|
+
/**
|
|
102
|
+
* @param {object} srv - SRV Definition
|
|
103
|
+
* @param {object} csnEntity - CSN Entity Definition
|
|
104
|
+
* @param {object} configurationEntity - Field Control configurations
|
|
105
|
+
* @param {object} configuration - Entity Configuration
|
|
106
|
+
*/
|
|
107
|
+
constructor(srv, csnEntity, configurationEntity, configuration) {
|
|
108
|
+
this.configuration = configuration;
|
|
109
|
+
this.srv = srv;
|
|
110
|
+
this.csnEntity = csnEntity;
|
|
111
|
+
this.configurationEntity = configurationEntity;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Call the onBeforeCalculateFC hook if defined.
|
|
116
|
+
* @param {object} updatedEntry The updated entity.
|
|
117
|
+
* @param {object} helperObject The helper context object.
|
|
118
|
+
* @returns {Promise<*>} Request
|
|
119
|
+
*/
|
|
120
|
+
async callOnBeforeCalculateFC(updatedEntry, helperObject) {
|
|
121
|
+
const onBeforeCalculateFC = getOnBeforeCalculateFC(this.configurationEntity);
|
|
122
|
+
|
|
123
|
+
if (onBeforeCalculateFC) {
|
|
124
|
+
return onBeforeCalculateFC(updatedEntry, helperObject);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Call the onBeforeSave hook if defined.
|
|
130
|
+
* @param {object} updatedEntry The updated entity.
|
|
131
|
+
* @param {object} req The CDS request object.
|
|
132
|
+
* @returns {Promise<void>} Request
|
|
133
|
+
*/
|
|
134
|
+
async callOnBeforeSave(updatedEntry, req) {
|
|
135
|
+
const onBeforeSave = getOnBeforeSave(this.configurationEntity);
|
|
136
|
+
|
|
137
|
+
if (onBeforeSave) {
|
|
138
|
+
return await onBeforeSave(updatedEntry, this.buildHelperObject(req));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Call the onAfterSave hook if defined.
|
|
144
|
+
* @param {object} updatedEntry The updated entity.
|
|
145
|
+
* @param {object} req The CDS request object.
|
|
146
|
+
* @returns {Promise<void>} Request
|
|
147
|
+
*/
|
|
148
|
+
async callOnAfterSave(updatedEntry, req) {
|
|
149
|
+
const onAfterSave = getOnAfterSave(this.configurationEntity);
|
|
150
|
+
|
|
151
|
+
if (onAfterSave) {
|
|
152
|
+
return await onAfterSave(updatedEntry, this.buildHelperObject(req));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build a helper context object for hooks.
|
|
158
|
+
* @param {object} req The CDS request object.
|
|
159
|
+
* @param {object} [context] Additional context.
|
|
160
|
+
* @returns {object} Helper object
|
|
161
|
+
*/
|
|
162
|
+
buildHelperObject(req, context = {}) {
|
|
163
|
+
return Object.assign({}, { srv: this.srv, req, context: Object.assign({ req }, context.context) });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Erase unavailable or unannotated fields from update data.
|
|
168
|
+
* @param {object} updatedEntry The updated entity.
|
|
169
|
+
* @param {object} updateData The update data object.
|
|
170
|
+
* @param {object} configuration The configuration object.
|
|
171
|
+
* @param {object} fieldControlAnnotationValues The annotations configuration object.
|
|
172
|
+
*/
|
|
173
|
+
eraseUnavailableDynamicFields(updatedEntry, updateData, configuration, fieldControlAnnotationValues) {
|
|
174
|
+
const { autoErase, blockUnannotatedValueChanges } = configuration;
|
|
175
|
+
|
|
176
|
+
const entityFCsSettings = getEntityFCAnnotationsMapping(this.csnEntity);
|
|
177
|
+
|
|
178
|
+
blockUnannotatedValueChanges && Object.entries(updateData).forEach(([ fieldName ]) => {
|
|
179
|
+
const fieldDefinition = this.csnEntity.elements[fieldName];
|
|
180
|
+
const fcFieldName = entityFCsSettings?.[fieldName]?.FCPath;
|
|
181
|
+
|
|
182
|
+
if (!fieldDefinition || fieldDefinition.key) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!fcFieldName || !updatedEntry.hasOwnProperty(fcFieldName)) {
|
|
187
|
+
delete updateData[fieldName];
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
autoErase && Object.entries(this.configurationEntity).forEach(([ fieldName ]) => {
|
|
192
|
+
const fieldDefinition = this.csnEntity.elements[fieldName];
|
|
193
|
+
|
|
194
|
+
if (fieldDefinition) {
|
|
195
|
+
const fcValue = fieldControlAnnotationValues[fieldName].fcValue;
|
|
196
|
+
const finalFieldName =
|
|
197
|
+
fieldDefinition.type === 'cds.Association'
|
|
198
|
+
? fieldDefinition.$generatedForeignKeys.at(0).name
|
|
199
|
+
: fieldName;
|
|
200
|
+
|
|
201
|
+
if (autoErase && fcValue <= fieldControlDictionary.ReadOnly) {
|
|
202
|
+
updateData[finalFieldName] = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Validate payload against field control configurations.
|
|
210
|
+
* @param {object} data The data to validate.
|
|
211
|
+
* @param {object} dataUpdate The data update to validate against.
|
|
212
|
+
* @param {object} [rawUpdate] The original, unmerged update payload as submitted by the caller.
|
|
213
|
+
* Used to detect whether a field was actually part of the change, since `dataUpdate` may be
|
|
214
|
+
* backfilled with mock defaults for live validation. Defaults to `dataUpdate`.
|
|
215
|
+
* @param {object} [previousData] The persisted state prior to this change, used to detect whether
|
|
216
|
+
* a read-only/hidden field's value actually changed. Defaults to `data`.
|
|
217
|
+
* @returns {object} Validation results containing any errors.
|
|
218
|
+
*/
|
|
219
|
+
async validatePayload(data, dataUpdate, rawUpdate = dataUpdate, previousData = data) {
|
|
220
|
+
const i18n = Utils.getBoundI18nBundle();
|
|
221
|
+
const fieldControlAnnotationValues = getFieldControlValues(data, this.csnEntity, i18n);
|
|
222
|
+
|
|
223
|
+
const validationPromises = Object.entries(dataUpdate).map(
|
|
224
|
+
async ([ key, entityValue ]) => {
|
|
225
|
+
const { fcValue, label, fcShortPath } = fieldControlAnnotationValues[key] || {};
|
|
226
|
+
const { validator } = this.configurationEntity[fcShortPath] || {};
|
|
227
|
+
const fieldErrors = [];
|
|
228
|
+
|
|
229
|
+
if (validator && fcValue >= fieldControlDictionary.Optional) {
|
|
230
|
+
const validationMessage = await validator.call(this.srv, entityValue, {
|
|
231
|
+
i18n,
|
|
232
|
+
entity: data,
|
|
233
|
+
fieldName: key
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (validationMessage) {
|
|
237
|
+
fieldErrors.push({
|
|
238
|
+
fieldName: key,
|
|
239
|
+
message: validationMessage
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (fcValue <= fieldControlDictionary.ReadOnly && rawUpdate.hasOwnProperty(key) && previousData[key] !== entityValue) {
|
|
245
|
+
fieldErrors.push({
|
|
246
|
+
fieldName: key,
|
|
247
|
+
message: i18n.getText('capdomain.validation.message.readOnly', [ label ])
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if ((entityValue === null || entityValue === '') && fcValue === fieldControlDictionary.Mandatory) {
|
|
252
|
+
fieldErrors.push({
|
|
253
|
+
fieldName: key,
|
|
254
|
+
message: i18n.getText('capdomain.validation.message.required', [ label ])
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return fieldErrors;
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const allResults = await Promise.all(validationPromises);
|
|
263
|
+
|
|
264
|
+
const errors = allResults.flat();
|
|
265
|
+
|
|
266
|
+
return { errors, fieldControlAnnotationValues };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Calculate field controls for associated entities.
|
|
271
|
+
* @param {object} entity The main entity.
|
|
272
|
+
* @param {object} req The CDS request object.
|
|
273
|
+
* @param {object} context The context object.
|
|
274
|
+
* @returns {Promise<Array>} Requests
|
|
275
|
+
*/
|
|
276
|
+
async calculateAssociatedEntitiesFC(entity, req, context) {
|
|
277
|
+
const requests = Object.entries(this.configuration.useImpl).map(async ([ associationName, targetSrvEntity ]) => {
|
|
278
|
+
const record = entity[associationName];
|
|
279
|
+
|
|
280
|
+
if (!record) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const fc = getSrvEntitiesFCs(this.srv)[targetSrvEntity];
|
|
285
|
+
|
|
286
|
+
return await fc.calculateFieldControls(record, req, context);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return await Promise.all(requests);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Calculate field controls for an Entity or array of Entities.
|
|
294
|
+
* @param {object | Array} entities Single entity or array of entities.
|
|
295
|
+
* @param {object | Array} req Request
|
|
296
|
+
* @param {object} context Context object.
|
|
297
|
+
* @returns {object | Array} Entity/Entities with field controls.
|
|
298
|
+
*/
|
|
299
|
+
async calculateFieldControls(entities, req, context = {}) {
|
|
300
|
+
if (!entities) {
|
|
301
|
+
return entities;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const entitiesArray = Array.isArray(entities)
|
|
305
|
+
? entities
|
|
306
|
+
: [ entities ];
|
|
307
|
+
|
|
308
|
+
const processEntitiesRequests = entitiesArray.map(async (entity) => {
|
|
309
|
+
const helperObject = this.buildHelperObject(req, context);
|
|
310
|
+
|
|
311
|
+
await this.calculateAssociatedEntitiesFC(entity, req, context);
|
|
312
|
+
|
|
313
|
+
await this.callOnBeforeCalculateFC(entity, helperObject);
|
|
314
|
+
|
|
315
|
+
const entityFCsSettings = getEntityFCAnnotationsMapping(this.csnEntity);
|
|
316
|
+
const fieldControls = Object.values(entityFCsSettings).reduce(
|
|
317
|
+
(fcAcc, { FCPath }) => {
|
|
318
|
+
if (fcAcc.hasOwnProperty(FCPath)) {
|
|
319
|
+
return fcAcc;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const fcValue = calculateFieldControl(this.configurationEntity[FCPath.replace('_fc', '')]?.fc, entity, helperObject);
|
|
323
|
+
|
|
324
|
+
if (fcValue !== null) {
|
|
325
|
+
fcAcc[FCPath] = fcValue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return fcAcc;
|
|
329
|
+
},
|
|
330
|
+
{}
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
Object.assign(entity, fieldControls);
|
|
334
|
+
|
|
335
|
+
return entity;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const processedEntities = await Promise.all(processEntitiesRequests);
|
|
339
|
+
|
|
340
|
+
return Array.isArray(entities)
|
|
341
|
+
? processedEntities
|
|
342
|
+
: processedEntities[0];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
FieldControls,
|
|
348
|
+
fieldControlDictionary,
|
|
349
|
+
handlers: {
|
|
350
|
+
setOnBeforeSave,
|
|
351
|
+
setOnBeforeCalculateFC,
|
|
352
|
+
|
|
353
|
+
getOnAfterSave,
|
|
354
|
+
setOnAfterSave
|
|
355
|
+
}
|
|
356
|
+
};
|