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.
Files changed (41) hide show
  1. package/.gitattributes +2 -0
  2. package/README.md +239 -0
  3. package/cds-plugin.js +5 -0
  4. package/index.js +33 -0
  5. package/package.json +25 -0
  6. package/src/FieldControls.js +356 -0
  7. package/src/Handlers.js +273 -0
  8. package/src/ServiceParser.js +56 -0
  9. package/src/SymbolHelper.js +125 -0
  10. package/src/Utils.js +61 -0
  11. package/src/cdsPluginImpl.js +22 -0
  12. package/src/defaultAnnotationValues.js +44 -0
  13. package/tests/.cdsrc.json +1 -0
  14. package/tests/.vscode/extensions.json +18 -0
  15. package/tests/.vscode/launch.json +18 -0
  16. package/tests/.vscode/settings.json +11 -0
  17. package/tests/.vscode/tasks.json +25 -0
  18. package/tests/_i18n/i18n.properties +5 -0
  19. package/tests/db/data/my.bookshop-Authors.csv +3 -0
  20. package/tests/db/data/my.bookshop-Books.csv +3 -0
  21. package/tests/db/data/my.bookshop-Details.csv +3 -0
  22. package/tests/db/data/my.bookshop-Request.csv +3 -0
  23. package/tests/db/data-model.cds +37 -0
  24. package/tests/eslint.config.js +6 -0
  25. package/tests/jest.config.js +9 -0
  26. package/tests/package.json +35 -0
  27. package/tests/srv/@FCDefinitions/Authors.js +24 -0
  28. package/tests/srv/@FCDefinitions/Books.js +23 -0
  29. package/tests/srv/@FCDefinitions/Details.js +16 -0
  30. package/tests/srv/@FCDefinitions/Request.js +22 -0
  31. package/tests/srv/CatalogService-annotations.cds +44 -0
  32. package/tests/srv/CatalogService.cds +46 -0
  33. package/tests/srv/CatalogService.js +21 -0
  34. package/tests/srv/_i18n/i18n_de.properties +6 -0
  35. package/tests/srv/_i18n/i18n_en.properties +6 -0
  36. package/tests/srv/cat-fiori-service.cds +7 -0
  37. package/tests/test/UsersApi.js +97 -0
  38. package/tests/test/authors.test.js +26 -0
  39. package/tests/test/books.test.js +97 -0
  40. package/tests/test/request.test.js +39 -0
  41. package/tests/test/setup.js +54 -0
package/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
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
@@ -0,0 +1,5 @@
1
+ const cdsPluginImpl = require('./src/cdsPluginImpl');
2
+
3
+ cds.env['enable:capdomain:plugin'] && cds.once('served', async () => {
4
+ cdsPluginImpl(Object.values(cds.services));
5
+ });
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
+ };