From e5833a09a85d38913413f9d09612fb6c96467dc4 Mon Sep 17 00:00:00 2001 From: Stefano Ricci <1219739+SteRiccio@users.noreply.github.com> Date: Mon, 7 Oct 2024 23:23:56 +0200 Subject: [PATCH] User extra properties (#3589) * layout adjustments (user edit form) * user extra props editor * editing user extra props * code cleanup * extra props save * layout adjutments * pass user to expression evaluator (WIP) * pass user to expression evaluation functions * make extra properties experimental * set user functions as experimental * added functions descriptions * use latest arena-core version * fixing tests * fixing tests * added userProp function editor * fixing tests --------- Co-authored-by: Stefano Ricci --- core/expressionParser/helpers/functions.js | 3 + core/i18n/resources/en/common.js | 13 ++ core/objectUtils.js | 5 + core/record/_record/recordNodesUpdater.js | 26 ++- .../_record/recordNodesUpdaterCommon.js | 3 +- core/record/_record/recordsCombiner.js | 6 +- core/survey/categoryItem.js | 6 +- core/survey/taxon.js | 13 +- core/user/_user/userProps.js | 3 + core/user/user.js | 7 +- .../_validator/validatorErrorKeys.js | 4 +- package.json | 2 +- .../service/DataImportJob/CsvDataImportJob.js | 3 +- .../service/DataImportJob/recordProvider.js | 1 + .../_recordManager/nodeCreationManager.js | 1 + .../_recordManager/nodeUpdateManager.js | 3 +- .../_recordManager/recordCreationManager.js | 2 +- .../_recordManager/recordUpdateManager.js | 28 ++-- .../_recordManager/recordValidationManager.js | 7 +- .../modules/survey/service/recordCheckJob.js | 19 +-- webapp/components/ItemEditButtonBar.js | 8 +- webapp/components/buttons/ButtonDelete.js | 3 +- .../components/expression/functionExamples.js | 62 ++++---- .../components/expression/nodes/call/call.js | 60 +------ .../nodes/call/callUserPropEditor.js | 46 ++++++ .../expression/nodes/call/functions.js | 74 +++++++++ webapp/components/form/Input/Input.js | 8 +- webapp/style/formBase.scss | 8 +- .../App/views/Users/UserEdit/UserEdit.js | 16 +- .../App/views/Users/UserEdit/UserEdit.scss | 14 +- .../UserExtraPropEditor.js | 148 ++++++++++++++++++ .../UserExtraPropsEditor.js | 99 ++++++++++++ .../UserEdit/UserExtraPropsEditor/index.js | 1 + .../views/Users/UserEdit/store/useEditUser.js | 8 + yarn.lock | 8 +- 35 files changed, 549 insertions(+), 169 deletions(-) create mode 100644 webapp/components/expression/nodes/call/callUserPropEditor.js create mode 100644 webapp/components/expression/nodes/call/functions.js create mode 100644 webapp/views/App/views/Users/UserEdit/UserExtraPropsEditor/UserExtraPropEditor.js create mode 100644 webapp/views/App/views/Users/UserEdit/UserExtraPropsEditor/UserExtraPropsEditor.js create mode 100644 webapp/views/App/views/Users/UserEdit/UserExtraPropsEditor/index.js diff --git a/core/expressionParser/helpers/functions.js b/core/expressionParser/helpers/functions.js index dae33bd12c..458d7db789 100644 --- a/core/expressionParser/helpers/functions.js +++ b/core/expressionParser/helpers/functions.js @@ -19,5 +19,8 @@ export const functionNames = { pow: 'pow', sum: 'sum', taxonProp: 'taxonProp', + userEmail: 'userEmail', + userName: 'userName', + userProp: 'userProp', uuid: 'uuid', } diff --git a/core/i18n/resources/en/common.js b/core/i18n/resources/en/common.js index 926bf941bd..5bc6450e81 100644 --- a/core/i18n/resources/en/common.js +++ b/core/i18n/resources/en/common.js @@ -1222,6 +1222,9 @@ $t(common.appNameFull) pow: 'Returns the value of a base raised to a power', rowIndex: 'Returns the current table row (or form) index', taxonProp: 'Returns the value of the specified $t(extraProp.label) of a taxon having the specified code', + userEmail: 'Returns the email of the logged in user', + userName: 'Returns the name of the logged in user', + userProp: 'Returns the value of the specified $t(extraProp.label) of the logged in user', uuid: 'Generates a UUID (universally unique identifier) that can be used as identifier (e.g. as a key attribute of an enity)', // SQL functions avg: 'Returns the average value of a numeric variable', @@ -1566,6 +1569,7 @@ Levels will be renamed into level_1, level_2... level_N and an extra 'area' prop extraProp: { label: 'Extra property', label_plural: 'Extra properties', + addExtraProp: 'Add extra property', dataTypes: { geometryPoint: 'Geometry Point', number: 'Number', @@ -1584,6 +1588,8 @@ Levels will be renamed into level_1, level_2... level_N and an extra 'area' prop dataTypeChanged: 'Data type changed from {{dataTypeOld}} to {{dataTypeNew}}', }, }, + name: 'Property {{position}} name', + value: 'Value', }, // ===== All validation errors @@ -1671,6 +1677,13 @@ Levels will be renamed into level_1, level_2... level_N and an extra 'area' prop unableToFindNodeSibling: 'unable to find sibling node: {{name}}', }, + extraPropEdit: { + nameInvalid: 'Invalid name', + nameRequired: 'Name required', + dataTypeRequired: 'Data type required', + valueRequired: 'Value required', + }, + nodeDefEdit: { analysisParentEntityRequired: 'Entity is required', applyIfDuplicate: '"$t(nodeDefEdit.expressionsProp.applyIf)" condition is duplicate', diff --git a/core/objectUtils.js b/core/objectUtils.js index 4665f76794..da5740dc80 100644 --- a/core/objectUtils.js +++ b/core/objectUtils.js @@ -9,6 +9,7 @@ export const keys = { dateCreated: 'dateCreated', dateModified: 'dateModified', draft: 'draft', + extra: 'extra', id: 'id', index: 'index', name: 'name', @@ -25,6 +26,7 @@ export const keysProps = { descriptions: 'descriptions', labels: 'labels', cycles: 'cycles', + extra: 'extra', } // ====== READ @@ -45,6 +47,9 @@ export const getLabel = (lang, defaultTo = null) => R.pipe(getLabels, R.propOr(d export const getDescriptions = getProp(keysProps.descriptions, {}) export const getDescription = (lang, defaultTo = null) => R.pipe(getDescriptions, R.propOr(defaultTo, lang)) +export const getExtra = getProp(keysProps.extra, {}) +export const getExtraProp = (extraPropKey) => R.pipe(getExtra, R.propOr(null, extraPropKey)) + export const getDate = (prop) => R.pipe(R.propOr(null, prop), R.unless(R.isNil, DateUtils.parseISO)) export const getDateCreated = getDate(keys.dateCreated) export const getDateModified = getDate(keys.dateModified) diff --git a/core/record/_record/recordNodesUpdater.js b/core/record/_record/recordNodesUpdater.js index 314b3437ec..f55133f9b6 100644 --- a/core/record/_record/recordNodesUpdater.js +++ b/core/record/_record/recordNodesUpdater.js @@ -65,10 +65,11 @@ const _addOrUpdateAttribute = } const _addEntityAndKeyValues = - ({ survey, entityDef, parentNode, keyValuesByDefUuid, sideEffect = false }) => + ({ user, survey, entityDef, parentNode, keyValuesByDefUuid, sideEffect = false }) => (record) => { const updateResult = new RecordUpdateResult({ record }) const updateResultDescendants = CoreRecordNodesUpdater.createNodeAndDescendants({ + user, survey, record, parentNode, @@ -107,7 +108,7 @@ const _addEntityAndKeyValues = } const _getOrCreateEntityByKeys = - ({ survey, entityDefUuid, valuesByDefUuid, insertMissingNodes, sideEffect = false }) => + ({ user, survey, entityDefUuid, valuesByDefUuid, insertMissingNodes, sideEffect = false }) => (record) => { if (NodeDef.getUuid(Survey.getNodeDefRoot(survey)) === entityDefUuid) { return { entity: RecordReader.getRootNode(record), updateResult: null } @@ -158,6 +159,7 @@ const _getOrCreateEntityByKeys = }) } const { entity: entityInserted, updateResult } = _addEntityAndKeyValues({ + user, survey, entityDef, parentNode: entityParent, @@ -169,11 +171,12 @@ const _getOrCreateEntityByKeys = } const getOrCreateEntityByKeys = - ({ survey, entityDefUuid, valuesByDefUuid, timezoneOffset, insertMissingNodes = false, sideEffect = false }) => + ({ user, survey, entityDefUuid, valuesByDefUuid, timezoneOffset, insertMissingNodes = false, sideEffect = false }) => async (record) => { const updateResult = new RecordUpdateResult({ record }) const { entity, updateResult: updateResultEntity } = _getOrCreateEntityByKeys({ + user, survey, entityDefUuid, valuesByDefUuid, @@ -185,6 +188,7 @@ const getOrCreateEntityByKeys = updateResult.merge(updateResultEntity) const dependentsUpdateResult = await afterNodesUpdate({ + user, survey, record: updateResultEntity.record, nodes: updateResultEntity.nodes, @@ -197,7 +201,7 @@ const getOrCreateEntityByKeys = } const updateAttributesInEntityWithValues = - ({ survey, entity, valuesByDefUuid, timezoneOffset, sideEffect = false }) => + ({ user, survey, entity, valuesByDefUuid, timezoneOffset, sideEffect = false }) => async (record) => { const updateResult = new RecordUpdateResult({ record }) @@ -207,6 +211,7 @@ const updateAttributesInEntityWithValues = updateResult.merge(nodeUpdateResult) const dependentsUpdateResult = await afterNodesUpdate({ + user, survey, record: nodeUpdateResult.record, nodes: nodeUpdateResult.nodes, @@ -258,12 +263,13 @@ const updateAttributesInEntityWithValues = } const updateAttributesWithValues = - ({ survey, entityDefUuid, valuesByDefUuid, timezoneOffset, insertMissingNodes = false, sideEffect = false }) => + ({ user, survey, entityDefUuid, valuesByDefUuid, timezoneOffset, insertMissingNodes = false, sideEffect = false }) => async (record) => { const updateResult = new RecordUpdateResult({ record }) // 1. get or create context entity const { entity, updateResult: updateResultEntity } = await getOrCreateEntityByKeys({ + user, survey, entityDefUuid, valuesByDefUuid, @@ -289,7 +295,7 @@ const updateAttributesWithValues = } const deleteNodesInEntityByNodeDefUuid = - ({ survey, entity, nodeDefUuids, sideEffect = false }) => + ({ user, survey, entity, nodeDefUuids, sideEffect = false }) => async (record) => { const updateResult = new RecordUpdateResult({ record }) @@ -299,7 +305,13 @@ const deleteNodesInEntityByNodeDefUuid = nodeUuidsToDelete.push(...children.map(Node.getUuid)) }) - const nodesDeleteUpdateResult = await deleteNodes({ survey, record, nodeUuids: nodeUuidsToDelete, sideEffect }) + const nodesDeleteUpdateResult = await deleteNodes({ + user, + survey, + record, + nodeUuids: nodeUuidsToDelete, + sideEffect, + }) return updateResult.merge(nodesDeleteUpdateResult) } diff --git a/core/record/_record/recordNodesUpdaterCommon.js b/core/record/_record/recordNodesUpdaterCommon.js index d2db469f4a..d13ebe5cad 100644 --- a/core/record/_record/recordNodesUpdaterCommon.js +++ b/core/record/_record/recordNodesUpdaterCommon.js @@ -9,12 +9,13 @@ import * as Validation from '@core/validation/validation' const { updateNodesDependents } = CoreRecordNodesUpdater -export const afterNodesUpdate = async ({ survey, record, nodes, timezoneOffset, sideEffect = false }) => { +export const afterNodesUpdate = async ({ user, survey, record, nodes, timezoneOffset, sideEffect = false }) => { // output const updateResult = new RecordUpdateResult({ record, nodes }) // 1. update dependent nodes const updateResultDependents = updateNodesDependents({ + user, survey, record, nodes, diff --git a/core/record/_record/recordsCombiner.js b/core/record/_record/recordsCombiner.js index f8a79dbac6..2e4ca5714e 100644 --- a/core/record/_record/recordsCombiner.js +++ b/core/record/_record/recordsCombiner.js @@ -139,7 +139,7 @@ const _replaceUpdatedNodesInEntities = ({ } export const replaceUpdatedNodes = - ({ survey, recordSource, timezoneOffset, sideEffect = false }) => + ({ user, survey, recordSource, timezoneOffset, sideEffect = false }) => async (recordTarget) => { const rootSource = RecordReader.getRootNode(recordSource) const rootTarget = RecordReader.getRootNode(recordTarget) @@ -156,6 +156,7 @@ export const replaceUpdatedNodes = sideEffect, }) return afterNodesUpdate({ + user, survey, record: updateResult.record, nodes: updateResult.nodes, @@ -362,7 +363,7 @@ const _mergeRecordsNodes = ({ } export const mergeRecords = - ({ survey, recordSource, timezoneOffset, sideEffect = false }) => + ({ user, survey, recordSource, timezoneOffset, sideEffect = false }) => async (recordTarget) => { const { cycle } = recordTarget const rootSource = RecordReader.getRootNode(recordSource) @@ -390,6 +391,7 @@ export const mergeRecords = }) } return afterNodesUpdate({ + user, survey, record: updateResult.record, nodes: updateResult.nodes, diff --git a/core/survey/categoryItem.js b/core/survey/categoryItem.js index a2185377ef..ef7616c0d8 100644 --- a/core/survey/categoryItem.js +++ b/core/survey/categoryItem.js @@ -45,6 +45,8 @@ export const newItem = (levelUuid, parentItemUuid = null, props = {}) => ({ export const { getDescription, getDescriptions, + getExtra, + getExtraProp, getLabels, getProps, getPropsDraft, @@ -63,10 +65,6 @@ export const getLabel = export const getLabelWithCode = (language) => (item) => CategoryItems.getLabelWithCode(item, language) export const getIndex = ObjectUtils.getProp(keysProps.index) -// ====== READ - Extra Props -export const getExtra = ObjectUtils.getProp(keysProps.extra) -export const getExtraProp = (prop) => R.pipe(getExtra, R.prop(prop)) - // ====== UPDATE export const assocProp = ({ key, value }) => ObjectUtils.setProp(key, value) // ====== UPDATE - Extra Props diff --git a/core/survey/taxon.js b/core/survey/taxon.js index a0618adb71..19195b873e 100644 --- a/core/survey/taxon.js +++ b/core/survey/taxon.js @@ -19,8 +19,8 @@ export const propKeys = { family: 'family', genus: 'genus', scientificName: 'scientificName', - extra: 'extra', index: 'index', + extra: ObjectUtils.keysProps.extra, } export const unlistedCode = 'UNL' @@ -41,7 +41,8 @@ export const newTaxon = ({ taxonomyUuid, code, family, genus, scientificName, ve }) // ====== READ -export const { getUuid, getProps, getPropsDraft, getPropsAndPropsDraft, setProp } = ObjectUtils +export const { getExtra, getExtraProp, getProps, getPropsDraft, getPropsAndPropsDraft, getUuid, isEqual, setProp } = + ObjectUtils export const getTaxonomyUuid = R.prop(keys.taxonomyUuid) export const getCode = ObjectUtils.getProp(propKeys.code, '') export const getFamily = ObjectUtils.getProp(propKeys.family, '') @@ -56,18 +57,10 @@ export const getVernacularLanguage = R.propOr('', keys.vernacularLanguage) export const getVernacularNameUuid = R.prop(keys.vernacularNameUuid) export const getVernacularName = R.propOr('', keys.vernacularName) -export const getExtra = ObjectUtils.getProp(propKeys.extra, {}) -export const getExtraProp = (extraPropKey) => (taxon) => { - const extra = getExtra(taxon) - return R.propOr(null, extraPropKey)(extra) -} - export const isUnlistedTaxon = R.pipe(getCode, R.equals(unlistedCode)) export const isUnknownTaxon = R.pipe(getCode, R.equals(unknownCode)) export const isUnkOrUnlTaxon = (taxon) => isUnlistedTaxon(taxon) || isUnknownTaxon(taxon) -export const { isEqual } = ObjectUtils - // ==== UPDATE export const assocVernacularNames = (lang, vernacularNames) => R.assocPath([keys.vernacularNames, lang], vernacularNames) diff --git a/core/user/_user/userProps.js b/core/user/_user/userProps.js index 0d97b9195c..7fb2830298 100644 --- a/core/user/_user/userProps.js +++ b/core/user/_user/userProps.js @@ -14,6 +14,8 @@ export const keysProps = { mapApiKeyByProvider: 'mapApiKeyByProvider', // restricted props (editable only by system admins) maxSurveys: 'maxSurveys', + // custom extra properties + extra: ObjectUtils.keysProps.extra, } const privateProps = [keysProps.mapApiKeyByProvider] @@ -55,6 +57,7 @@ export const assocMapApiKey = return assocProp(keysProps.mapApiKeyByProvider)(mapApiKeyByProviderUpdated)(user) } export const assocMaxSurveys = assocProp(keysProps.maxSurveys) +export const assocExtra = assocProp(keysProps.extra) const dissocListOfProps = (propsArray) => (user) => propsArray.reduce((acc, propKey) => R.dissocPath([userKeys.props, propKey])(acc), user) diff --git a/core/user/user.js b/core/user/user.js index 192c9ee6e8..45c30f7f75 100644 --- a/core/user/user.js +++ b/core/user/user.js @@ -10,23 +10,21 @@ import * as UserProps from './_user/userProps' import { userStatus } from './_user/userStatus' export { keys } from './_user/userKeys' +export { keysProps } from './_user/userProps' export { userStatus } from './_user/userStatus' export const nameMaxLength = 128 export const { keysPrefs, keysSurveyPrefs } = UserPrefs -export const { keysProps } = UserProps // ====== READ -export const { isEqual } = ObjectUtils -export const { getUuid } = ObjectUtils +export const { getAuthGroups, getExtra, getExtraProp, getUuid, isEqual } = ObjectUtils export const getName = R.propOr('', keys.name) export const getEmail = R.prop(keys.email) export const getInvitedBy = R.prop(keys.invitedBy) export const getInvitedDate = R.prop(keys.invitedDate) export const getPassword = R.prop(keys.password) export const getLang = R.propOr('en', keys.lang) -export const { getAuthGroups } = ObjectUtils export const getPrefs = R.propOr({}, keys.prefs) export const getProps = R.propOr({}, keys.props) export const getProfilePicture = R.prop(keys.profilePicture) @@ -111,6 +109,7 @@ export const { assocTitle, assocMapApiKey, assocMaxSurveys, + assocExtra, titleKeys, titleKeysArray, newProps, diff --git a/core/validation/_validator/validatorErrorKeys.js b/core/validation/_validator/validatorErrorKeys.js index 75fdd83a66..0905f8cd7d 100644 --- a/core/validation/_validator/validatorErrorKeys.js +++ b/core/validation/_validator/validatorErrorKeys.js @@ -70,8 +70,10 @@ export const ValidatorErrorKeys = { }, extraPropEdit: { - nameInvalid: 'validationErrors.extraPropEdit.nameInvalid', dataTypeRequired: 'validationErrors.extraPropEdit.dataTypeRequired', + nameInvalid: 'validationErrors.extraPropEdit.nameInvalid', + nameRequired: 'validationErrors.extraPropEdit.nameRequired', + valueRequired: 'validationErrors.extraPropEdit.valueRequired', }, nodeDefEdit: { analysisParentEntityRequired: 'validationErrors.nodeDefEdit.analysisParentEntityRequired', diff --git a/package.json b/package.json index 9dce59ba62..c5f04fff4b 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@mui/x-data-grid": "^7.14.0", "@mui/x-date-pickers": "^7.14.0", "@mui/x-tree-view": "^7.7.1", - "@openforis/arena-core": "^0.0.206", + "@openforis/arena-core": "^0.0.207", "@openforis/arena-server": "^0.1.35", "@reduxjs/toolkit": "^2.2.5", "@sendgrid/mail": "^8.1.3", diff --git a/server/modules/dataImport/service/DataImportJob/CsvDataImportJob.js b/server/modules/dataImport/service/DataImportJob/CsvDataImportJob.js index 070614727c..2cb7f30829 100644 --- a/server/modules/dataImport/service/DataImportJob/CsvDataImportJob.js +++ b/server/modules/dataImport/service/DataImportJob/CsvDataImportJob.js @@ -153,7 +153,7 @@ export default class CsvDataImportJob extends DataImportBaseJob { async onRowItem({ valuesByDefUuid, errors }) { const { context, tx } = this - const { survey, nodeDefUuid, includeFiles, insertMissingNodes } = context + const { survey, nodeDefUuid, includeFiles, insertMissingNodes, user } = context if (this.isCanceled()) { return @@ -186,6 +186,7 @@ export default class CsvDataImportJob extends DataImportBaseJob { const updateResult = new RecordUpdateResult({ record: this.currentRecord }) const { entity, updateResult: entityUpdateResult } = await Record.getOrCreateEntityByKeys({ + user, survey, entityDefUuid, valuesByDefUuid, diff --git a/server/modules/dataImport/service/DataImportJob/recordProvider.js b/server/modules/dataImport/service/DataImportJob/recordProvider.js index 694dcd14d3..08eb452da9 100644 --- a/server/modules/dataImport/service/DataImportJob/recordProvider.js +++ b/server/modules/dataImport/service/DataImportJob/recordProvider.js @@ -66,6 +66,7 @@ const fetchOrCreateRecord = async ({ valuesByDefUuid, currentRecord, context, tx let record = null if (dryRun) { const { record: recordCreated } = await RecordUpdater.createRootEntity({ + user, survey, record: recordToInsert, sideEffect: true, diff --git a/server/modules/record/manager/_recordManager/nodeCreationManager.js b/server/modules/record/manager/_recordManager/nodeCreationManager.js index 1e6b97294b..125bd3d708 100644 --- a/server/modules/record/manager/_recordManager/nodeCreationManager.js +++ b/server/modules/record/manager/_recordManager/nodeCreationManager.js @@ -83,6 +83,7 @@ export const insertNode = async ( if (NodeDef.isEntity(nodeDef)) { const descendantsCreateResult = RecordNodesUpdater.createDescendants({ + user, survey, record, parentNode: node, diff --git a/server/modules/record/manager/_recordManager/nodeUpdateManager.js b/server/modules/record/manager/_recordManager/nodeUpdateManager.js index 0c89f17ecb..b126ec3b37 100644 --- a/server/modules/record/manager/_recordManager/nodeUpdateManager.js +++ b/server/modules/record/manager/_recordManager/nodeUpdateManager.js @@ -142,10 +142,11 @@ const _reloadNodes = async ({ surveyId, record, nodes }, tx) => { } export const updateNodesDependents = async ( - { survey, record, nodes, timezoneOffset, persistNodes = true, sideEffect = false }, + { user, survey, record, nodes, timezoneOffset, persistNodes = true, sideEffect = false }, tx ) => { const { record: recordUpdatedDependents, nodes: nodesUpdated } = Record.updateNodesDependents({ + user, survey, record, nodes, diff --git a/server/modules/record/manager/_recordManager/recordCreationManager.js b/server/modules/record/manager/_recordManager/recordCreationManager.js index eb65594672..2645b86385 100644 --- a/server/modules/record/manager/_recordManager/recordCreationManager.js +++ b/server/modules/record/manager/_recordManager/recordCreationManager.js @@ -31,7 +31,7 @@ export const insertRecord = async (user, surveyId, record, system = false, clien const _createRecordAndNodes = async ({ user, survey, cycle }) => { const record = Record.newRecord(user, cycle) - const { record: recordWithNodes } = await Record.createRootEntity({ survey, record }) + const { record: recordWithNodes } = await Record.createRootEntity({ user, survey, record }) return recordWithNodes } diff --git a/server/modules/record/manager/_recordManager/recordUpdateManager.js b/server/modules/record/manager/_recordManager/recordUpdateManager.js index 5e9c3e4df9..8b8daf3fdc 100644 --- a/server/modules/record/manager/_recordManager/recordUpdateManager.js +++ b/server/modules/record/manager/_recordManager/recordUpdateManager.js @@ -262,7 +262,15 @@ const _updateNodeAndValidateRecordUniqueness = async ( let recordUpdated = recordUpdated1 const { record: recordUpdated2, nodes: updatedNodesAndDependents } = await _onNodesUpdate( - { survey, record: recordUpdated, nodesUpdated, timezoneOffset, nodesUpdateListener, nodesValidationListener }, + { + user, + survey, + record: recordUpdated, + nodesUpdated, + timezoneOffset, + nodesUpdateListener, + nodesValidationListener, + }, t ) recordUpdated = recordUpdated2 @@ -316,7 +324,7 @@ const _getDependentNodesToValidate = ({ survey, record, nodes }) => { } const _onNodesUpdate = async ( - { survey, record, nodesUpdated, timezoneOffset, nodesUpdateListener, nodesValidationListener }, + { user, survey, record, nodesUpdated, timezoneOffset, nodesUpdateListener, nodesValidationListener }, t ) => { // 1. update record and notify @@ -326,7 +334,7 @@ const _onNodesUpdate = async ( // 2. update dependent nodes const { record: recordUpdatedDependentNodes, nodes: updatedDependentNodes } = - await NodeUpdateManager.updateNodesDependents({ survey, record, nodes: nodesUpdated, timezoneOffset }, t) + await NodeUpdateManager.updateNodesDependents({ user, survey, record, nodes: nodesUpdated, timezoneOffset }, t) if (nodesUpdateListener) { nodesUpdateListener(updatedDependentNodes) } @@ -342,12 +350,7 @@ const _onNodesUpdate = async ( const nodesToValidate = _getDependentNodesToValidate({ survey, record, nodes: updatedNodesAndDependents }) const recordUpdated = await validateNodesAndPersistToRDB( - { - survey, - record, - nodes: nodesToValidate, - nodesValidationListener, - }, + { user, survey, record, nodes: nodesToValidate, nodesValidationListener }, t ) return { @@ -396,17 +399,14 @@ const _afterNodesUpdate = async ({ survey, record, nodes }, t) => { await RecordRepository.updateRecordDateModified({ surveyId, recordUuid }, t) } -const validateNodesAndPersistToRDB = async ({ survey, record, nodes, nodesValidationListener = null }, t) => { +const validateNodesAndPersistToRDB = async ({ user, survey, record, nodes, nodesValidationListener = null }, t) => { const nodesArray = Object.values(nodes) const nodesToValidate = nodesArray.reduce( (nodesAcc, node) => (Node.isDeleted(node) ? nodesAcc : { ...nodesAcc, [Node.getUuid(node)]: node }), {} ) const validations = await RecordValidationManager.validateNodesAndPersistValidation( - survey, - record, - nodesToValidate, - true, + { user, survey, record, nodes: nodesToValidate, validateRecordUniqueness: true }, t ) if (nodesValidationListener) { diff --git a/server/modules/record/manager/_recordManager/recordValidationManager.js b/server/modules/record/manager/_recordManager/recordValidationManager.js index 1479f4a529..6eb5881c9c 100644 --- a/server/modules/record/manager/_recordManager/recordValidationManager.js +++ b/server/modules/record/manager/_recordManager/recordValidationManager.js @@ -42,9 +42,12 @@ const isRootUniqueNodesUpdated = ({ survey, nodes }) => }) )(nodes) -export const validateNodesAndPersistValidation = async (survey, record, nodes, validateRecordUniqueness, tx) => { +export const validateNodesAndPersistValidation = async ( + { user, survey, record, nodes, validateRecordUniqueness = false }, + tx +) => { // 1. validate node values - const nodesValueValidation = await RecordValidator.validateNodes({ survey, record, nodes }) + const nodesValueValidation = await RecordValidator.validateNodes({ user, survey, record, nodes }) const nodesValueValidationsByUuid = Validation.getFieldValidations(nodesValueValidation) // 1.a. workaround: always define value field validation even when validation is valid to allow cleaning up errors later Object.entries(nodesValueValidationsByUuid).forEach(([nodeUuid, nodeValueValidation]) => { diff --git a/server/modules/survey/service/recordCheckJob.js b/server/modules/survey/service/recordCheckJob.js index dbb5addea0..29f6143e5a 100644 --- a/server/modules/survey/service/recordCheckJob.js +++ b/server/modules/survey/service/recordCheckJob.js @@ -109,12 +109,13 @@ export default class RecordCheckJob extends Job { } async _checkRecord(surveyAndNodeDefs, recordUuid) { + const { surveyId, user, tx } = this const { survey, nodeDefAddedUuids, nodeDefUpdatedUuids, nodeDefDeletedUuids } = surveyAndNodeDefs // this.logDebug(`checking record ${recordUuid}`) // 1. fetch record and nodes - let record = await RecordManager.fetchRecordAndNodesByUuid({ surveyId: this.surveyId, recordUuid }, this.tx) + let record = await RecordManager.fetchRecordAndNodesByUuid({ surveyId, recordUuid }, tx) // 2. remove deleted nodes if (!R.isEmpty(nodeDefDeletedUuids)) { @@ -123,7 +124,7 @@ export default class RecordCheckJob extends Job { this.surveyId, nodeDefDeletedUuids, record, - this.tx + tx ) record = recordDeletedNodes || record } @@ -152,7 +153,7 @@ export default class RecordCheckJob extends Job { nodeDefAddedOrUpdatedUuids, record, nodesInsertedByUuid, - this.tx + tx ) record = recordUpdate || record Object.assign(allUpdatedNodesByUuid, nodesUpdatedDefaultValues) @@ -162,9 +163,9 @@ export default class RecordCheckJob extends Job { const allUpdatedNodesArray = Object.values(allUpdatedNodesByUuid) for await (const node of allUpdatedNodesArray) { if (Node.isCreated(node)) { - this.nodesBatchInserter.addItem(node, this.tx) + this.nodesBatchInserter.addItem(node, tx) } else if (Node.isUpdated(node)) { - this.nodesBatchUpdater.addItem(node, this.tx) + this.nodesBatchUpdater.addItem(node, tx) } } @@ -181,7 +182,7 @@ export default class RecordCheckJob extends Job { if (nodeDefAddedOrUpdatedUuids.length > 0 || !R.isEmpty(allUpdatedNodesByUuid)) { // this.logDebug(`validating record ${recordUuid}`) - await _validateNodes(survey, nodeDefAddedOrUpdatedUuids, record, allUpdatedNodesByUuid, this.tx) + await _validateNodes({ user, survey, nodeDefAddedOrUpdatedUuids, record, nodes: allUpdatedNodesByUuid }, this.tx) } // this.logDebug('record check complete') } @@ -277,13 +278,13 @@ const _clearRecordKeysValidation = (record) => { return record } -const _validateNodes = async (survey, nodeDefAddedUpdatedUuids, record, nodes, tx) => { +const _validateNodes = async ({ user, survey, nodeDefAddedOrUpdatedUuids, record, nodes }, tx) => { const nodesToValidate = { ...nodes, } // Include parent nodes of new/updated node defs (needed for min/max count validation) - nodeDefAddedUpdatedUuids.forEach((nodeDefUuid) => { + nodeDefAddedOrUpdatedUuids.forEach((nodeDefUuid) => { const def = Survey.getNodeDefByUuid(nodeDefUuid)(survey) const parentNodes = Record.getNodesByDefUuid(NodeDef.getParentUuid(def))(record) parentNodes.forEach((parentNode) => { @@ -292,7 +293,7 @@ const _validateNodes = async (survey, nodeDefAddedUpdatedUuids, record, nodes, t }) // Record keys uniqueness must be validated after RDB generation - await RecordManager.validateNodesAndPersistValidation(survey, record, nodesToValidate, false, tx) + await RecordManager.validateNodesAndPersistValidation({ user, survey, record, nodes: nodesToValidate }, tx) } RecordCheckJob.type = 'RecordCheckJob' diff --git a/webapp/components/ItemEditButtonBar.js b/webapp/components/ItemEditButtonBar.js index 4f754172a1..da92296e08 100644 --- a/webapp/components/ItemEditButtonBar.js +++ b/webapp/components/ItemEditButtonBar.js @@ -22,13 +22,17 @@ export const ItemEditButtonBar = (props) => {
{!editing && !readOnly && ( <> - + {onEdit && } {onDelete && } )} {editing && ( <> - + )} diff --git a/webapp/components/buttons/ButtonDelete.js b/webapp/components/buttons/ButtonDelete.js index 3da77d3851..958b30df63 100644 --- a/webapp/components/buttons/ButtonDelete.js +++ b/webapp/components/buttons/ButtonDelete.js @@ -8,9 +8,10 @@ export const ButtonDelete = (props) => { return (
+ ) +} + +CallUserPropEditor.propTypes = CallEditorPropTypes diff --git a/webapp/components/expression/nodes/call/functions.js b/webapp/components/expression/nodes/call/functions.js new file mode 100644 index 0000000000..a80b14ca97 --- /dev/null +++ b/webapp/components/expression/nodes/call/functions.js @@ -0,0 +1,74 @@ +import * as Expression from '@core/expressionParser/expression' + +import { CallCategoryItemPropEditor } from './callCategoryItemPropEditor' +import { CallCountEditor } from './callCountEditor' +import { CallIncludesEditor } from './callIncludesEditor' +import { CallIsEmptyEditor } from './callIsEmptyEditor' +import { CallIsNotEmptyEditor } from './callIsNotEmptyEditor' +import { CallTaxonPropEditor } from './callTaxonPropEditor' +import { CallUserPropEditor } from './callUserPropEditor' + +const { functionNames } = Expression + +const complexFunctions = { + rowIndex: { + labelKey: 'nodeDefEdit.functionName.rowIndex', + exprString: 'index($context)', + }, +} + +export const functions = { + [functionNames.isEmpty]: { + label: 'isEmpty(...)', + component: CallIsEmptyEditor, + }, + [functionNames.isNotEmpty]: { + label: 'isNotEmpty(...)', + component: CallIsNotEmptyEditor, + }, + [functionNames.count]: { + label: 'count(...)', + component: CallCountEditor, + }, + [functionNames.includes]: { + label: 'includes(...)', + component: CallIncludesEditor, + }, + [functionNames.now]: { + label: 'now()', + callee: functionNames.now, + }, + [functionNames.categoryItemProp]: { + label: 'categoryItemProp(...)', + component: CallCategoryItemPropEditor, + }, + [functionNames.taxonProp]: { + label: 'taxonProp(...)', + component: CallTaxonPropEditor, + }, + [functionNames.userEmail]: { + label: 'userEmail()', + callee: functionNames.userEmail, + }, + [functionNames.userName]: { + label: 'userName()', + callee: functionNames.userName, + }, + [functionNames.userProp]: { + label: 'userProp()', + component: CallUserPropEditor, + }, + [functionNames.uuid]: { + label: 'uuid()', + callee: functionNames.uuid, + }, + ...complexFunctions, +} + +export const getComplexFunctionNameByExpression = (exprString) => { + if (!exprString) return null + return Object.keys(complexFunctions).find((key) => { + const funcObj = complexFunctions[key] + return funcObj.exprString === exprString + }) +} diff --git a/webapp/components/form/Input/Input.js b/webapp/components/form/Input/Input.js index c389170eea..fd25b5a9bb 100644 --- a/webapp/components/form/Input/Input.js +++ b/webapp/components/form/Input/Input.js @@ -24,11 +24,11 @@ export const Input = React.forwardRef((props, ref) => { name = undefined, numberFormat = null, onChange = null, - onFocus = () => {}, - onBlur = () => {}, + onFocus = undefined, + onBlur = undefined, placeholder = null, readOnly = false, - textTransformFunction = (s) => s, + textTransformFunction = undefined, title: titleProp = null, // defaults to value type = 'text', validation = null, @@ -53,7 +53,7 @@ export const Input = React.forwardRef((props, ref) => { selectionRef.current = [input.selectionStart, input.selectionEnd] } if (onChange) { - onChange(textTransformFunction(newValue)) + onChange(textTransformFunction ? textTransformFunction(newValue) : newValue) } }, [disabled, inputRef, onChange, selectionAllowed, textTransformFunction] diff --git a/webapp/style/formBase.scss b/webapp/style/formBase.scss index 624a729a45..188280b3eb 100644 --- a/webapp/style/formBase.scss +++ b/webapp/style/formBase.scss @@ -104,10 +104,6 @@ button:disabled, color: white; background-color: $red; border-color: rgba($red, 0.5) !important; - - &:hover { - background-color: rgba($red, 0.9) !important; - } } .btn-primary, @@ -179,9 +175,7 @@ button:disabled, text-transform: unset; &.btn-danger { - color: $white; - background-color: $red; - border-color: rgba($red, 0.5) !important; + color: $red; } &.btn-s { diff --git a/webapp/views/App/views/Users/UserEdit/UserEdit.js b/webapp/views/App/views/Users/UserEdit/UserEdit.js index db2b2c5b68..7bd107b6c7 100644 --- a/webapp/views/App/views/Users/UserEdit/UserEdit.js +++ b/webapp/views/App/views/Users/UserEdit/UserEdit.js @@ -7,8 +7,7 @@ import * as Survey from '@core/survey/survey' import * as User from '@core/user/user' import * as Validation from '@core/validation/validation' import * as AuthGroup from '@core/auth/authGroup' - -import { useI18n } from '@webapp/store/system' +import * as ProcessUtils from '@core/processUtils' import ProfilePicture from '@webapp/components/profilePicture' import { FormItem, Input, NumberFormats } from '@webapp/components/form/Input' @@ -16,15 +15,15 @@ import Checkbox from '@webapp/components/form/checkbox' import DropdownUserTitle from '@webapp/components/form/DropdownUserTitle' import { ButtonSave, ButtonDelete, ButtonInvite, Button } from '@webapp/components' +import { appModuleUri, userModules } from '@webapp/app/appModules' import { useSurveyInfo } from '@webapp/store/survey' +import { useI18n } from '@webapp/store/system' import { useAuthCanUseMap } from '@webapp/store/user/hooks' +import { useEditUser } from './store' import DropdownUserGroup from '../DropdownUserGroup' - import ProfilePictureEditor from './ProfilePictureEditor' - -import { useEditUser } from './store' -import { appModuleUri, userModules } from '@webapp/app/appModules' +import { UserExtraPropsEditor } from './UserExtraPropsEditor' const UserEdit = () => { const { userUuid } = useParams() @@ -51,6 +50,7 @@ const UserEdit = () => { onUpdateProfilePicture, onSurveyAuthGroupChange, onSurveyManagerChange, + onExtraChange, onSave, onRemove, onInviteRepeat, @@ -75,7 +75,7 @@ const UserEdit = () => { const editingSameUser = User.isEqual(user)(userToUpdate) return ( -
+
{canEdit ? ( { )} + {ProcessUtils.ENV.experimentalFeatures && } + {(canEdit || canRemove || invitationExpired) && (