diff --git a/.env.template b/.env.template index cd8df88fe0..9be2290499 100644 --- a/.env.template +++ b/.env.template @@ -78,3 +78,7 @@ ADMIN_EMAIL= # Admin user password: used only when default system admin user is created the first time # it MUST BE DELETED after the first startup ADMIN_PASSWORD= + +# Experimental features +# if set to true, experimental features will be enabled in the UI +EXPERIMENTAL_FEATURES=false diff --git a/common/analysis/chain.js b/common/analysis/chain.js index b7b69b760a..75e5d236d5 100644 --- a/common/analysis/chain.js +++ b/common/analysis/chain.js @@ -24,7 +24,6 @@ export const keys = { export const keysProps = { labels: ObjectUtils.keysProps.labels, descriptions: ObjectUtils.keysProps.descriptions, - cycles: ObjectUtils.keysProps.cycles, hasSamplingDesign: 'hasSamplingDesign', samplingDesign: 'samplingDesign', analysisNodeDefs: 'analysisNodeDefs', @@ -46,7 +45,6 @@ export const { getUuid, getProps, getPropsDiff, - getCycles, getDateCreated, getDateModified, getDescriptions, diff --git a/common/analysis/samplingNodeDefs.js b/common/analysis/samplingNodeDefs.js index 308e78149e..f748c2f08c 100644 --- a/common/analysis/samplingNodeDefs.js +++ b/common/analysis/samplingNodeDefs.js @@ -84,8 +84,8 @@ const determinePlotAreaNodeDefs = ({ survey, chain }) => { const chainUuid = Chain.getUuid(chain) const cycleKeys = Survey.getCycleKeys(survey) const baseUnitNodeDef = Survey.getBaseUnitNodeDef({ chain })(survey) - const descentants = Survey.getDescendantsAndSelf({ nodeDef: baseUnitNodeDef })(survey) - const descendantEntities = descentants.filter( + const descendants = Survey.getNodeDefDescendantsAndSelf({ nodeDef: baseUnitNodeDef })(survey) + const descendantEntities = descendants.filter( (descendantEntity) => NodeDef.isEntity(descendantEntity) && (NodeDef.isMultiple(descendantEntity) || NodeDef.isRoot(descendantEntity)) ) diff --git a/common/model/db/tables/chain/select.js b/common/model/db/tables/chain/select.js index 1662faad32..0aa4d400c6 100644 --- a/common/model/db/tables/chain/select.js +++ b/common/model/db/tables/chain/select.js @@ -1,5 +1,3 @@ -import * as Chain from '@common/analysis/chain' - function _getSelectFields({ count, includeScript }) { if (count) { return ['count(*)'] @@ -14,7 +12,6 @@ function _getSelectFields({ count, includeScript }) { * Generate the select query for the processing_chain table by the given parameters. * * @param {!object} params - The query parameters. - * @param {string} [params.cycle=null] - The survey cycle to filter by. * @param {string} [params.chainUuid=null] - The chain uuid to filter by. * @param {boolean} [params.count=false] - Whether to count. * @param {boolean} [params.includeScript=false] - Whether to include R scripts. @@ -22,7 +19,7 @@ function _getSelectFields({ count, includeScript }) { * @returns {string} - The select query. */ export function getSelect(params) { - const { cycle = null, chainUuid = null, count = false, includeScript = false } = params + const { chainUuid = null, count = false, includeScript = false } = params this.getSelectFields = _getSelectFields.bind(this) @@ -31,7 +28,6 @@ export function getSelect(params) { FROM ${this.nameAliased} - ${cycle ? `WHERE (${this.columnProps})->'${Chain.keysProps.cycles}' @> '"${cycle}"'` : ''} ${chainUuid ? `WHERE ${this.columnUuid} = '${chainUuid}'` : ''} ` } diff --git a/common/model/db/tables/dataNodeDef/columnNodeDef.js b/common/model/db/tables/dataNodeDef/columnNodeDef.js index 2e94dfd813..a4d8c4b6d6 100644 --- a/common/model/db/tables/dataNodeDef/columnNodeDef.js +++ b/common/model/db/tables/dataNodeDef/columnNodeDef.js @@ -35,6 +35,7 @@ const colTypesGetterByType = { }, [nodeDefType.date]: () => [SQL.types.date], [nodeDefType.decimal]: () => [SQL.types.decimal], + [nodeDefType.geo]: () => [SQL.types.varchar], [nodeDefType.entity]: () => [SQL.types.uuid], [nodeDefType.file]: () => [SQL.types.uuid, SQL.types.varchar], [nodeDefType.integer]: () => [SQL.types.bigint], diff --git a/core/arrayUtils.js b/core/arrayUtils.js index 4c90ce951a..47be5632f2 100644 --- a/core/arrayUtils.js +++ b/core/arrayUtils.js @@ -80,6 +80,11 @@ const sortByProps = (props) => (array) => return 0 }) +const toArray = (value) => { + if (Objects.isNil(value)) return value + return Array.isArray(value) ? value : [value] +} + export const ArrayUtils = { addOrRemoveItem, addIfNotEmpty, @@ -91,4 +96,5 @@ export const ArrayUtils = { first, last, sortByProps, + toArray, } diff --git a/core/expressionParser/helpers/functions.js b/core/expressionParser/helpers/functions.js index e856d48385..dae33bd12c 100644 --- a/core/expressionParser/helpers/functions.js +++ b/core/expressionParser/helpers/functions.js @@ -4,6 +4,7 @@ export const functionNames = { count: 'count', distance: 'distance', first: 'first', + geoPolygon: 'geoPolygon', includes: 'includes', index: 'index', isEmpty: 'isEmpty', diff --git a/core/i18n/resources/en/common.js b/core/i18n/resources/en/common.js index ef35ab0a20..a48b5391f5 100644 --- a/core/i18n/resources/en/common.js +++ b/core/i18n/resources/en/common.js @@ -226,6 +226,12 @@ Do you want to proceed?`, pageNotFound: 'Page not found', }, + geo: { + area: 'Area', + vertices: 'Vertices', + perimeter: 'Perimeter', + }, + files: { header: 'Files', missing: ' Missing files: {{count}}', @@ -802,8 +808,8 @@ Merge cannot be performed.`, mapView: { createRecord: 'Create new record', editRecord: 'Edit record', - altitude: 'Altitude (m)', earthMap: 'Earth Map', + elevation: 'Elevation (m)', locationEditInfo: 'Double click on the map or drag the marker to update the location', locationUpdated: 'Location updated', options: { @@ -1156,8 +1162,8 @@ $t(common.cantUndoWarning)`, invalid: 'Invalid expression: {{details}}', missingFunctionParameters: 'Missing function parameters', undefinedFunction: 'Undefined function: {{name}}', - functionHasTooFewArguments: 'Function {{fnName}} requires at least {{minArity}} (got {{numArgs}})', - functionHasTooManyArguments: 'Function {{fnName}} only accepts at most {{maxArity}} (got {{numArgs}})', + functionHasTooFewArguments: 'Function {{fnName}} requires at least {{minArity}} arguments (got {{numArgs}})', + functionHasTooManyArguments: 'Function {{fnName}} only accepts at most {{maxArity}} arguments (got {{numArgs}})', }, // ====== Help views @@ -1203,14 +1209,14 @@ $t(common.appNameFull) isEmpty: 'Returns true if the argument has no value specified', isNotEmpty: 'Returns true if the argument has some value specified', last: 'Returns the last value or node of the specified multiple attribute or entity', - ln: 'Take the natural logarithm of x', - log10: 'Take the base 10 logarithm of x', - max: 'Take the maximum of the arguments', - min: 'Take the minimum of the arguments', + ln: 'Returns the natural logarithm of X', + log10: 'Returns the base 10 logarithm of X', + max: 'Returns the maximum of the arguments', + min: 'Returns the minimum of the arguments', now: 'Returns the current date or time', parent: 'Returns the parent entity of the specified node', - pow: 'Raise a number X to the power P', - rowIndex: 'Gives the current row index', + 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', uuid: 'Generates a UUID (universally unique identifier) that can be used as identifier (e.g. as a key attribute of an enity)', // SQL functions @@ -1223,6 +1229,10 @@ $t(common.appNameFull) }, basicProps: { analysis: 'Analysis', + autoIncrementalKey: { + label: 'Auto incremental', + info: 'Value will be automatically generated', + }, displayAs: 'Display as', displayIn: 'Display in', entitySource: 'Entity Source', @@ -1247,6 +1257,7 @@ $t(common.appNameFull) areaBasedEstimate: 'Area-based estimate', defaultValues: 'Default values', defaultValueEvaluatedOneTime: 'Default value evaluated only one time', + defaultValuesNotEditableForAutoIncrementalKey: 'Default values not editable because auto incremental key is set', hidden: 'Hide in entry form', hiddenInMobile: 'Hidden in Arena Mobile', hiddenWhenNotRelevant: 'Hidden when not relevant', @@ -1361,6 +1372,7 @@ E.g. this.region = region_attribute_name coordinate: 'Coordinate', date: 'Date', decimal: 'Decimal', + geo: 'Geospatial', entity: 'Table or form', file: 'File', integer: 'Integer', @@ -1429,6 +1441,11 @@ $t(surveyForm.formEntryActions.confirmPromote)`, altitude: 'Altitude', altitudeAccuracy: 'Altitude accuracy', }, + nodeDefGeo: { + confirmDelete: 'Delete this Geospatial value?', + geoJSON: 'GeoJSON', + invalidGeoJsonFileUploaded: 'Invalid GeoJSON file uploaded', + }, nodeDefEntityForm: { addNewEntity: 'Add new {{name}}', confirmDelete: 'Are you sure you want to delete this entity?', @@ -1446,6 +1463,10 @@ $t(surveyForm.formEntryActions.confirmPromote)`, fileUuid: 'File uuid', fileName: 'File name', }, + nodeDefsTreeSelectMode: { + allNodeDefs: 'All nodes', + onlyPages: 'Only pages', + }, step: { entry: 'Entry', cleansing: 'Cleansing', diff --git a/core/numberUtils.js b/core/numberUtils.js index 25991f975d..2e467d3b64 100644 --- a/core/numberUtils.js +++ b/core/numberUtils.js @@ -12,6 +12,39 @@ BigNumber.config({ }, }) +export const areaUnits = { + squareMeter: 'squareMeter', + squareFoot: 'squareFoot', + acre: 'acre', + hectare: 'hectare', +} + +const areaUnitToSquareMetersConversionFactor = { + [areaUnits.acre]: 4046.85642199999983859016, + [areaUnits.hectare]: 10000, + [areaUnits.squareMeter]: 1, + [areaUnits.squareFoot]: 0.09290304, +} + +export const lengthUnits = { + meter: 'meter', + foot: 'foot', +} + +const lengthUnitToMetersConversionFactor = { + [lengthUnits.meter]: 1, + [lengthUnits.foot]: 0.3048, +} + +export const abbreviationByUnit = { + [areaUnits.squareMeter]: 'm²', + [areaUnits.squareFoot]: 'ft²', + [areaUnits.acre]: 'ac', + [areaUnits.hectare]: 'ha', + [lengthUnits.meter]: 'm', + [lengthUnits.foot]: 'ft', +} + export const toNumber = (num) => (Objects.isEmpty(num) ? NaN : Number(num)) export const isInteger = A.pipe(toNumber, Number.isInteger) @@ -51,3 +84,16 @@ export const roundToPrecision = (value, precision = NaN) => { * @returns {string} - The formatted value or null if the value was null. */ export const formatInteger = (value) => formatDecimal(value, 0) + +/** + * Returns the modulus of the specified value. The result will always be a positive number. + * @param {!number} modulus - The modulus to apply. + * @returns {number} - The result of the modulus (always positive or 0). + */ +export const mod = (modulus) => (value) => ((value % modulus) + modulus) % modulus + +export const squareMetersToUnit = (unit) => (value) => + Objects.isNil(value) ? NaN : value / areaUnitToSquareMetersConversionFactor[unit] + +export const metersToUnit = (unit) => (value) => + Objects.isNil(value) ? NaN : value / lengthUnitToMetersConversionFactor[unit] diff --git a/core/objectUtils.js b/core/objectUtils.js index 45a21e1ccd..4665f76794 100644 --- a/core/objectUtils.js +++ b/core/objectUtils.js @@ -104,6 +104,14 @@ export const setInPath = export const dissocTemporary = R.unless(R.isNil, R.dissoc(keys.temporary)) +export const keepNonEmptyProps = (obj) => + Object.entries(obj).reduce((acc, [key, value]) => { + if (!isBlank(value)) { + acc[key] = value + } + return acc + }, {}) + // ====== UTILS / uuid const _getProp = (propNameOrExtractor) => (item) => typeof propNameOrExtractor === 'string' ? R.path(propNameOrExtractor.split('.'))(item) : propNameOrExtractor(item) diff --git a/core/processUtils.js b/core/processUtils.js index 7677e48f1f..8498f96e91 100644 --- a/core/processUtils.js +++ b/core/processUtils.js @@ -74,6 +74,8 @@ const ENV = { fileStorageAwsSecretAccessKey: process.env.FILE_STORAGE_AWS_SECRET_ACCESS_KEY, fileStorageAwsS3BucketName: process.env.FILE_STORAGE_AWS_S3_BUCKET_NAME, fileStorageAwsS3BucketRegion: process.env.FILE_STORAGE_AWS_S3_BUCKET_REGION, + // Experimental features + experimentalFeatures: isTrue(process.env.EXPERIMENTAL_FEATURES), } module.exports = { diff --git a/core/record/_record/recordNodeValueUpdater.js b/core/record/_record/recordNodeValueUpdater.js index 389cef048f..207ff3e053 100644 --- a/core/record/_record/recordNodeValueUpdater.js +++ b/core/record/_record/recordNodeValueUpdater.js @@ -32,7 +32,10 @@ export const updateAttributeValue = ({ const attributeUpdated = A.pipe( Node.assocValue(value), Node.assocUpdated(true), - Node.assocDateModified(dataModifiedParam ?? new Date()) + Node.assocDateModified(dataModifiedParam ?? new Date()), + (nodeUpdated) => + // reset defaultValueApplied meta information + Node.isDefaultValueApplied(attribute) ? Node.assocIsDefaultValueApplied(false)(nodeUpdated) : nodeUpdated )(attribute) const updateResult = new RecordUpdateResult({ record }) diff --git a/core/record/_record/recordNodesUpdater.js b/core/record/_record/recordNodesUpdater.js index dd35570f6e..314b3437ec 100644 --- a/core/record/_record/recordNodesUpdater.js +++ b/core/record/_record/recordNodesUpdater.js @@ -3,7 +3,6 @@ import { RecordUpdater as CoreRecordUpdater, RecordNodesUpdater as CoreRecordNodesUpdater, RecordUpdateResult, - Promises, } from '@openforis/arena-core' import * as Survey from '@core/survey/survey' @@ -234,7 +233,7 @@ const updateAttributesInEntityWithValues = ) // update attribute values - await Promises.each(valuesByDefUuidEntriesInDescendantAttributes, async ([attributeDefUuid, value]) => { + for await (const [attributeDefUuid, value] of valuesByDefUuidEntriesInDescendantAttributes) { const attributeDef = Survey.getNodeDefByUuid(attributeDefUuid)(survey) const { record: currentRecord } = updateResult @@ -254,7 +253,7 @@ const updateAttributesInEntityWithValues = })(currentRecord) await updateDependentNodes(attributeUpdateResult) - }) + } return updateResult } diff --git a/core/survey/_survey/surveyNodeDefs.js b/core/survey/_survey/surveyNodeDefs.js index 5bbe23fc64..5b47a1c0d8 100644 --- a/core/survey/_survey/surveyNodeDefs.js +++ b/core/survey/_survey/surveyNodeDefs.js @@ -311,16 +311,17 @@ export const getNodeDefPath = } export const getHierarchy = - (filterFn = NodeDef.isEntity) => + (filterFn = NodeDef.isEntity, cycle = undefined) => (survey) => { let length = 1 const h = (array, nodeDef) => { - const childDefs = [ - ...(NodeDef.isEntity(nodeDef) && !NodeDef.isVirtual(nodeDef) - ? R.pipe(getNodeDefChildren(nodeDef), R.filter(filterFn))(survey) - : []), - ] - + const childDefs = [] + if (NodeDef.isEntity(nodeDef) && !NodeDef.isVirtual(nodeDef)) { + const childDefsNotFiltered = cycle + ? getNodeDefChildrenSorted({ nodeDef, cycle })(survey) + : getNodeDefChildren(nodeDef)(survey) + childDefs.push(...childDefsNotFiltered.filter(filterFn)) + } length += childDefs.length const item = { ...nodeDef, children: childDefs.reduce(h, []) } array.push(item) @@ -350,7 +351,7 @@ export const traverseHierarchyItemSync = (nodeDefItem, visitorFn, depth = 0) => } export const visitDescendantsAndSelf = - ({ visitorFn, nodeDef = null, cycle = null, traverseMethod = TraverseMethod.bfs }) => + ({ nodeDef = null, cycle = null, visitorFn, traverseMethod = TraverseMethod.bfs }) => (survey) => { const nodeDefToVisit = nodeDef ?? getNodeDefRoot(survey) return Surveys.visitDescendantsAndSelfNodeDef({ @@ -377,22 +378,18 @@ export const findDescendants = return descendants } -export const getDescendantsAndSelf = - ({ nodeDef = null }) => +export const getNodeDefDescendantsAndSelf = + ({ nodeDef = null, cycle = null, traverseMethod = TraverseMethod.bfs } = {}) => (survey) => { const descendants = [] - const queue = new Queue() - - queue.enqueue(nodeDef || getNodeDefRoot(survey)) - - while (!queue.isEmpty()) { - const nodeDefCurrent = queue.dequeue() - - descendants.push(nodeDefCurrent) - - const childrenDefs = getNodeDefChildren(nodeDefCurrent)(survey) - queue.enqueueItems(childrenDefs) - } + visitDescendantsAndSelf({ + nodeDef, + cycle, + visitorFn: (visitedNodeDef) => { + descendants.push(visitedNodeDef) + }, + traverseMethod, + })(survey) return descendants } diff --git a/core/survey/nodeDef.js b/core/survey/nodeDef.js index b3d93d0941..9d746837e7 100644 --- a/core/survey/nodeDef.js +++ b/core/survey/nodeDef.js @@ -52,6 +52,7 @@ export const propKeys = { descriptions: ObjectUtils.keysProps.descriptions, enumerate: 'enumerate', // only for multiple entities key: 'key', + autoIncrementalKey: 'autoIncrementalKey', labels: ObjectUtils.keysProps.labels, multiple: 'multiple', name: ObjectUtils.keys.name, @@ -175,6 +176,17 @@ export const visibleFieldsDefaultByType = { [nodeDefType.taxon]: [valuePropsTaxon.code, valuePropsTaxon.scientificName, valuePropsTaxon.vernacularName], } +export const createAutoIncrementalKeyDefaultValues = ({ nodeDef, nodeDefParent }) => { + const nodeDefName = getName(nodeDef) + const nodeDefParentName = getName(nodeDefParent) + return [ + NodeDefExpression.createExpression({ expression: '1', applyIf: 'index($context) == 0' }), + NodeDefExpression.createExpression({ + expression: `Math.max(parent($context).${nodeDefParentName}.${nodeDefName}) + 1`, + }), + ] +} + // ==== READ export const { @@ -197,6 +209,7 @@ export const getName = getProp(propKeys.name, '') export const getCycles = getProp(propKeys.cycles, []) export const isKey = ObjectUtils.isPropTrue(propKeys.key) +export const isAutoIncrementalKey = ObjectUtils.isPropTrue(propKeys.autoIncrementalKey) export const isRoot = R.pipe(getParentUuid, R.isNil) export const isMultiple = ObjectUtils.isPropTrue(propKeys.multiple) export const isSingle = R.pipe(isMultiple, R.not) @@ -221,6 +234,7 @@ export const isCode = isType(nodeDefType.code) export const isCoordinate = isType(nodeDefType.coordinate) export const isDate = isType(nodeDefType.date) export const isDecimal = isType(nodeDefType.decimal) +export const isGeo = isType(nodeDefType.geo) export const isFile = isType(nodeDefType.file) export const isInteger = isType(nodeDefType.integer) export const isTaxon = isType(nodeDefType.taxon) @@ -453,6 +467,10 @@ export const mergePropsAdvanced = (propsAdvanced) => (nodeDef) => R.pipe(getPropsAdvanced, R.mergeLeft(propsAdvanced), (propsAdvancedUpdated) => assocPropsAdvanced(propsAdvancedUpdated, nodeDef) )(nodeDef) +export const assocDefaultValues = (defaultValues) => + mergePropsAdvanced({ [keysPropsAdvanced.defaultValues]: defaultValues }) +export const assocDefaultValueEvaluatedOnlyOneTime = (evaluatedOnlyOneTime) => + mergePropsAdvanced({ [keysPropsAdvanced.defaultValueEvaluatedOneTime]: evaluatedOnlyOneTime }) export const assocValidations = (validations) => mergePropsAdvanced({ [keysPropsAdvanced.validations]: validations }) export const dissocTemporary = R.dissoc(keys.temporary) export const assocProp = ({ key, value }) => @@ -498,7 +516,10 @@ export const convertToType = ({ toType }) => (nodeDef) => { const propsUpdated = R.pick(commonAttributePropsKeys)(getProps(nodeDef)) - const propsAdvancedUpdated = R.pick(commonAttributePropsAdvancedKeys)(getPropsAdvanced(nodeDef)) + const propsAdvancedToKeep = isAutoIncrementalKey(nodeDef) + ? commonAttributePropsAdvancedKeys.filter((prop) => prop !== keysPropsAdvanced.defaultValues) + : commonAttributePropsAdvancedKeys + const propsAdvancedUpdated = R.pick(propsAdvancedToKeep)(getPropsAdvanced(nodeDef)) const layout = getLayout(nodeDef) const layoutUpdated = Object.entries(layout).reduce((acc, [cycleKey, cycleLayout]) => { @@ -598,6 +619,7 @@ export const canHaveDefaultValue = (nodeDef) => nodeDefType.coordinate, nodeDefType.date, nodeDefType.decimal, + nodeDefType.geo, nodeDefType.integer, nodeDefType.taxon, nodeDefType.text, @@ -653,3 +675,18 @@ export const clearNotApplicableProps = (cycle) => (nodeDef) => { export const canHaveMobileProps = (cycle) => (nodeDef) => canBeHiddenInMobile(nodeDef) || canIncludeInMultipleEntitySummary(cycle)(nodeDef) + +export const canHaveAutoIncrementalKey = ({ nodeDef, nodeDefParent }) => { + if (!isKey(nodeDef) || !isInteger(nodeDef)) return false + + const defaultValues = getDefaultValues(nodeDef) + if (defaultValues.length === 0) return true + + const autoIncrementalDefaultValues = createAutoIncrementalKeyDefaultValues({ nodeDef, nodeDefParent }) + return ( + defaultValues.length === autoIncrementalDefaultValues.length && + autoIncrementalDefaultValues.every((defaultValue, index) => + NodeDefExpression.isSimilarTo(defaultValue)(defaultValues[index]) + ) + ) +} diff --git a/core/survey/nodeDefExpression.js b/core/survey/nodeDefExpression.js index 3b9330a706..ab532ad477 100644 --- a/core/survey/nodeDefExpression.js +++ b/core/survey/nodeDefExpression.js @@ -53,6 +53,16 @@ export const isPlaceholder = R.propEq(keys.placeholder, true) export const isEmpty = (expression = {}) => StringUtils.isBlank(getExpression(expression)) && StringUtils.isBlank(getApplyIf(expression)) +export const isSimilarTo = (expressionA) => (expressionB) => { + if (isEmpty(expressionA) && isEmpty(expressionB)) return true + if (isEmpty(expressionA) || isEmpty(expressionB)) return false + const prepareExpr = (expr) => StringUtils.removeSuffix('\n')(expr.replaceAll(' ', '')) + return ( + prepareExpr(getExpression(expressionA)) === prepareExpr(getExpression(expressionB)) && + prepareExpr(getApplyIf(expressionA)) === prepareExpr(getApplyIf(expressionB)) + ) +} + // ====== UPDATE const assocProp = (propName, value) => R.pipe(R.assoc(propName, value), R.dissoc(keys.placeholder)) diff --git a/core/survey/nodeDefType.js b/core/survey/nodeDefType.js index 631625d827..432bd82c3b 100644 --- a/core/survey/nodeDefType.js +++ b/core/survey/nodeDefType.js @@ -7,6 +7,7 @@ export const nodeDefType = { boolean: 'boolean', code: 'code', coordinate: 'coordinate', + geo: 'geo', taxon: 'taxon', file: 'file', entity: 'entity', diff --git a/core/survey/survey.js b/core/survey/survey.js index 87a7033cfb..c8650811eb 100644 --- a/core/survey/survey.js +++ b/core/survey/survey.js @@ -184,9 +184,9 @@ export const { getHierarchy, traverseHierarchyItem, traverseHierarchyItemSync, - getDescendantsAndSelf, visitDescendantsAndSelf, findDescendants, + getNodeDefDescendantsAndSelf, getNodeDefDescendantsInSingleEntities, getNodeDefDescendantAttributesInSingleEntities, getNodeDefAncestorMultipleEntity, diff --git a/package.json b/package.json index 69f75a0196..b49863f60d 100644 --- a/package.json +++ b/package.json @@ -100,17 +100,23 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.521.0", + "@codemirror/autocomplete": "^6.18.0", + "@codemirror/lang-javascript": "^6.2.2", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@mui/material": "^6.0.0", "@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.203", + "@openforis/arena-core": "^0.0.204", "@openforis/arena-server": "^0.1.34", "@reduxjs/toolkit": "^2.2.5", "@sendgrid/mail": "^8.1.3", "@shopify/draggable": "^1.1.3", + "@uiw/react-codemirror": "^4.23.0", + "@turf/area": "^7.1.0", + "@turf/centroid": "^7.1.0", + "@turf/length": "^7.1.0", "ace-builds": "^1.32.6", "adm-zip": "^0.5.10", "archiver": "^7.0.1", @@ -122,7 +128,7 @@ "circle-to-polygon": "^2.2.0", "classnames": "^2.5.1", "cluster": "^0.7.7", - "codemirror": "^5.65.5", + "codemirror": "^6.0.1", "csv": "^6.3.9", "d3": "^7.9.0", "dotenv": "^16.4.5", diff --git a/server/modules/analysis/api/chainApi.js b/server/modules/analysis/api/chainApi.js index 6e8f13d175..ee3d41ad8b 100644 --- a/server/modules/analysis/api/chainApi.js +++ b/server/modules/analysis/api/chainApi.js @@ -14,10 +14,10 @@ export const init = (app) => { app.post('/survey/:surveyId/chain/', AuthMiddleware.requireRecordAnalysisPermission, async (req, res, next) => { try { - const { cycle, surveyId } = Request.getParams(req) + const { surveyId } = Request.getParams(req) const user = Request.getUser(req) - const chain = await AnalysisService.create({ user, surveyId, cycle }) + const chain = await AnalysisService.create({ user, surveyId }) res.json(chain) } catch (error) { @@ -32,9 +32,9 @@ export const init = (app) => { AuthMiddleware.requireRecordAnalysisPermission, async (req, res, next) => { try { - const { surveyId, surveyCycleKey: cycle, offset, limit } = Request.getParams(req) + const { surveyId, offset, limit } = Request.getParams(req) - const list = await AnalysisService.fetchChains({ surveyId, cycle, offset, limit }) + const list = await AnalysisService.fetchChains({ surveyId, offset, limit }) res.json({ list }) } catch (error) { @@ -48,9 +48,9 @@ export const init = (app) => { AuthMiddleware.requireRecordAnalysisPermission, async (req, res, next) => { try { - const { surveyId, surveyCycleKey: cycle } = Request.getParams(req) + const { surveyId } = Request.getParams(req) - const count = await AnalysisService.countChains({ surveyId, cycle }) + const count = await AnalysisService.countChains({ surveyId }) res.json({ count }) } catch (error) { diff --git a/server/modules/analysis/manager/chain/index.js b/server/modules/analysis/manager/chain/index.js index 21050ef8cd..0badd579b2 100644 --- a/server/modules/analysis/manager/chain/index.js +++ b/server/modules/analysis/manager/chain/index.js @@ -28,27 +28,26 @@ export const insertChain = async ({ user, surveyId, chain }, client = DB.client) return chainDb }) -export const create = async ({ user, surveyId, cycle }) => { - let newChain = ChainFactory.createInstance({ cycles: [cycle] }) - +export const create = async ({ user, surveyId }) => { const survey = await SurveyManager.fetchSurveyAndNodeDefsBySurveyId({ surveyId, draft: true, advanced: true }) - const defaultLang = Survey.getDefaultLanguage(Survey.getSurveyInfo(survey)) - const validation = await ChainValidator.validateChain({ chain: newChain, defaultLang, survey }) + const surveyInfo = Survey.getSurveyInfo(survey) + const cycles = Survey.getCycleKeys(surveyInfo) + const defaultLang = Survey.getDefaultLanguage(surveyInfo) - newChain = Chain.assocValidation(validation)(newChain) + let chain = ChainFactory.createInstance({ cycles }) - return insertChain({ - surveyId, - user, - chain: newChain, - }) + const validation = await ChainValidator.validateChain({ chain, defaultLang, survey }) + + chain = Chain.assocValidation(validation)(chain) + + return insertChain({ surveyId, user, chain }) } // ====== READ export const { countChains, fetchChains, fetchChain } = ChainRepository // ====== UPDATE -export const { updateChain, removeChainCycles } = ChainRepository +export const { updateChain } = ChainRepository export const updateChainStatusExec = async ({ user, surveyId, chainUuid, statusExec }) => DB.client.tx(async (tx) => { @@ -101,28 +100,26 @@ export const persistChain = async ({ user, surveyId, chain }, client) => { // ====== DELETE -export const _deleteChain = async ({ user, surveyId, chainUuid = false, noCycle = false }, client = DB.client) => { - const deletedChains = await ChainRepository.deleteChain({ surveyId, chainUuid, noCycle }, client) - - const deletedChainsUuids = deletedChains.map(Chain.getUuid) +export const _deleteChain = async ({ user, surveyId, chainUuid }, client = DB.client) => { + const deletedChain = await ChainRepository.deleteChain({ surveyId, chainUuid }, client) + const deletedChainUuid = Chain.getUuid(deletedChain) const survey = await SurveyManager.fetchSurveyAndNodeDefsBySurveyId( { surveyId, draft: true, advanced: true, includeAnalysis: true }, (client = DB.client) ) const nodeDefsUuidsInDeleteChains = Survey.getNodeDefsArray(survey) - .filter((_nodeDef) => deletedChainsUuids.includes(NodeDef.getChainUuid(_nodeDef))) + .filter((_nodeDef) => deletedChainUuid === NodeDef.getChainUuid(_nodeDef)) .map(NodeDef.getUuid) await NodeDefService.markNodeDefsDeleted({ user, surveyId, nodeDefUuids: nodeDefsUuidsInDeleteChains }, client) - return deletedChains + return deletedChain } export const deleteChain = async ({ user, surveyId, chainUuid }, client = DB.client) => client.tx(async (tx) => { - const deletedChains = await _deleteChain({ user, surveyId, chainUuid }, tx) - const deletedChain = deletedChains[0] + const deletedChain = await _deleteChain({ user, surveyId, chainUuid }, tx) const content = { [ActivityLog.keysContent.uuid]: chainUuid, @@ -133,6 +130,3 @@ export const deleteChain = async ({ user, surveyId, chainUuid }, client = DB.cli markSurveyDraft(surveyId, tx), ]) }) - -export const deleteChainWithoutCycle = async ({ surveyId }, client = DB.client) => - client.tx(async (tx) => _deleteChain({ surveyId, noCycle: true }, tx)) diff --git a/server/modules/analysis/manager/index.js b/server/modules/analysis/manager/index.js index e68457cd53..8eb646dd3f 100644 --- a/server/modules/analysis/manager/index.js +++ b/server/modules/analysis/manager/index.js @@ -1,14 +1,4 @@ // ====== Chain -export { - create, - countChains, - fetchChains, - fetchChain, - updateChain, - updateChainStatusExec, - removeChainCycles, - deleteChain, - deleteChainWithoutCycle, -} from './chain' +export { create, countChains, fetchChains, fetchChain, updateChain, updateChainStatusExec, deleteChain } from './chain' export { cleanChains } from './chainsCleanManager' diff --git a/server/modules/analysis/repository/chain/delete.js b/server/modules/analysis/repository/chain/delete.js index 14adfe755d..589dc51784 100644 --- a/server/modules/analysis/repository/chain/delete.js +++ b/server/modules/analysis/repository/chain/delete.js @@ -1,5 +1,3 @@ -import * as Chain from '@common/analysis/chain' - import * as DB from '../../../../db' import { TableChain } from '../../../../../common/model/db' @@ -9,26 +7,21 @@ import { TableChain } from '../../../../../common/model/db' * * @param {!object} params - The query parameters. * @param {!string} params.surveyId - The survey id. - * @param {string} [params.chainUuid=null] - The chain uuid to delete. - * @param {boolean} [params.noCycle=false] - Whether to delete chains with no cycles. + * @param {!string} [params.chainUuid] - The chain uuid to delete. * @param {pgPromise.IDatabase} [client=db] - The database client. * * @returns {Promise} - The result promise. */ export const deleteChain = async (params, client = DB.client) => { - const { surveyId, chainUuid = null, noCycle = false } = params - - if (!chainUuid && !noCycle) - throw new Error(`One between chainUuid and noCycle are required. {chainUuid:${chainUuid}, noCycle:${noCycle}}`) + const { surveyId, chainUuid } = params const tableChain = new TableChain(surveyId) - return client.any( + return client.one( `DELETE FROM ${tableChain.nameQualified} - WHERE - ${chainUuid ? TableChain.columnSet.uuid : `jsonb_array_length(props->'${Chain.keysProps.cycles}')`} = $1 + WHERE ${TableChain.columnSet.uuid} = $1 RETURNING *`, - chainUuid ? [chainUuid] : [0], + [chainUuid], DB.transformCallback ) } diff --git a/server/modules/analysis/repository/chain/index.js b/server/modules/analysis/repository/chain/index.js index ec11ead77a..6aeacecdc5 100644 --- a/server/modules/analysis/repository/chain/index.js +++ b/server/modules/analysis/repository/chain/index.js @@ -3,6 +3,6 @@ export { insertMany } from './insert' export { countChains, fetchChains, fetchChain } from './read' -export { updateChain, removeChainCycles } from './update' +export { updateChain } from './update' export { deleteChain } from './delete' diff --git a/server/modules/analysis/repository/chain/read.js b/server/modules/analysis/repository/chain/read.js index a161760bbf..21e16bd3ea 100644 --- a/server/modules/analysis/repository/chain/read.js +++ b/server/modules/analysis/repository/chain/read.js @@ -13,27 +13,25 @@ export const transformCallback = (row) => { } /** - * Count the processing chains by the given survey id and the optional survey cycle. + * Count the processing chains by the given survey id. * * @param {!object} params - The query parameters. * @param {!string} params.surveyId - The survey id. - * @param {string} [params.cycle=null] - The survey cycle. * @param {BaseProtocol} [client=db] - The database client. * * @returns {Promise} - The result promise. */ export const countChains = async (params, client = DB.client) => { - const { surveyId, cycle = null } = params + const { surveyId } = params const tableChain = new TableChain(surveyId) - return client.one(`${tableChain.getSelect({ surveyId, cycle, count: true })}`, [], (row) => Number(row.count)) + return client.one(`${tableChain.getSelect({ surveyId, count: true })}`, [], (row) => Number(row.count)) } /** - * Fetches all processing chains by the given survey id and the optional survey cycle. + * Fetches all processing chains by the given survey id. * * @param {!object} params - The query parameters. * @param {!string} params.surveyId - The survey id. - * @param {string} [params.cycle=null] - The survey cycle. * @param {number} [params.offset=0] - The select query offset. * @param {number} [params.limit=null] - The select query limit. * @param {boolean} [params.includeScript=false] - Whether to include the R scripts. @@ -42,12 +40,12 @@ export const countChains = async (params, client = DB.client) => { * @returns {Promise} - The result promise. */ export const fetchChains = async (params, client = DB.client) => { - const { surveyId, cycle = null, offset = 0, limit = null, includeScript = false } = params + const { surveyId, offset = 0, limit = null, includeScript = false } = params const tableChain = new TableChain(surveyId) return client.map( - `${tableChain.getSelect({ surveyId, cycle, includeScript })} + `${tableChain.getSelect({ surveyId, includeScript })} ORDER BY ${tableChain.columnDateCreated} DESC LIMIT ${limit || 'ALL'} OFFSET ${offset}`, diff --git a/server/modules/analysis/repository/chain/update.js b/server/modules/analysis/repository/chain/update.js index 0375b59bfb..06acf2bc3b 100644 --- a/server/modules/analysis/repository/chain/update.js +++ b/server/modules/analysis/repository/chain/update.js @@ -1,6 +1,5 @@ import * as DB from '../../../../db' -import * as Chain from '@common/analysis/chain' import { TableChain } from '../../../../../common/model/db' /** @@ -9,7 +8,7 @@ import { TableChain } from '../../../../../common/model/db' * @param {!object} params - The query parameters. * @param {!string} params.surveyId - The survey id. * @param {!string} params.chainUuid - The processing chain uuid. - * @param {Object} [params.fields={}] - A object containing the fields to update. + * @param {{[key:string]: any}} [params.fields={}] - A object containing the fields to update. * @param {boolean} [params.dateExecuted=false] - Whether to update date executed to current time. * @param {boolean} [params.dateModified=false] - Whether to update date modified to current time. * @param {pgPromise.IDatabase} [client=db] - The database client. @@ -41,23 +40,3 @@ export const updateChain = async (params, client = DB.client) => { [chainUuid, ...Object.values(fields)] ) } - -/** - * Remove survey cycles from all processing chains. - * - * @param {!object} params - The query parameters. - * @param {!string} params.surveyId - The survey id. - * @param {!Array.} params.cycles - The cycles to remove. - * @param {pgPromise.IDatabase} [client=db] - The database client. - * - * @returns {Promise} - The result promise. - */ -export const removeChainCycles = async (params, client) => { - const { surveyId, cycles } = params - const tableChain = new TableChain(surveyId) - return client.none( - `UPDATE ${tableChain.nameQualified} - SET ${TableChain.columnSet.props} = jsonb_set(${TableChain.columnSet.props}, '{${Chain.keysProps.cycles}}', - (${TableChain.columnSet.props}->'${Chain.keysProps.cycles}') ${cycles.map((cycle) => `- '${cycle}'`).join(' ')})` - ) -} diff --git a/server/modules/analysis/service/chainSummaryGenerator.js b/server/modules/analysis/service/chainSummaryGenerator.js index 3fba57e9c1..f672bc9dd6 100644 --- a/server/modules/analysis/service/chainSummaryGenerator.js +++ b/server/modules/analysis/service/chainSummaryGenerator.js @@ -159,7 +159,6 @@ const generateChainSummary = async ({ surveyId, chainUuid, cycle, lang: langPara label: Chain.getLabel(lang, defaultLang)(chain), selectedLanguage: lang, selectedCycle: getCycleLabel(cycle), - cycles: Chain.getCycles(chain).map(getCycleLabel), samplingDesign: Chain.hasSamplingDesign(chain), baseUnit: NodeDef.getName(baseUnitNodeDef), baseUnitEntityKeys, diff --git a/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/collectExpressionConverter.js b/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/collectExpressionConverter.js index b1d9403970..d24f664dbd 100644 --- a/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/collectExpressionConverter.js +++ b/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/collectExpressionConverter.js @@ -109,6 +109,7 @@ const convert = ({ survey, nodeDefCurrent, expression, advancedExpressionEditor // geo namespace // idm:distance is deprecated in Collect { pattern: '(geo|idm):distance', replace: 'distance' }, + { pattern: 'geo:polygon', replace: 'geoPolygon' }, // math namespace { pattern: /math:PI\(\)/, replace: 'Math.PI' }, // convert directly some functions from math:fnName to Math.fnName diff --git a/server/modules/geo/api/geoApi.js b/server/modules/geo/api/geoApi.js index a12aa925cb..5bb00bb0e0 100644 --- a/server/modules/geo/api/geoApi.js +++ b/server/modules/geo/api/geoApi.js @@ -14,8 +14,8 @@ import { PlanetApi } from './planetApi' const uriPrefix = '/survey/:surveyId/geo/' const whispApiUrl = 'https://whisp.openforis.org/api/' -// free altitude API urls -const altitudeApiUrls = [ +// free elevation API urls +const elevationApiUrls = [ ({ lat, lng }) => `https://api.opentopodata.org/v1/aster30m?locations=${lat},${lng}`, ({ lat, lng }) => `https://api.open-elevation.com/api/v1/lookup?locations=${lat},${lng}`, ] @@ -78,20 +78,20 @@ export const init = (app) => { } ) - app.get(`${uriPrefix}map/altitude`, AuthMiddleware.requireMapUsePermission, async (req, res) => { + app.get(`${uriPrefix}map/elevation`, AuthMiddleware.requireMapUsePermission, async (req, res) => { const { lat, lng } = Request.getParams(req) - let altitude = null - await Promises.each(altitudeApiUrls, async (urlPattern) => { - if (!Objects.isEmpty(altitude)) return + let elevation = null + await Promises.each(elevationApiUrls, async (urlPattern) => { + if (!Objects.isEmpty(elevation)) return try { const url = urlPattern({ lat, lng }) const { data } = await axios.get(url, { timeout: 10000 }) - altitude = data?.results?.[0]?.elevation + elevation = data?.results?.[0]?.elevation } catch (error) { // ignore it } }) - res.json(altitude) + res.json(elevation) }) app.get(`${uriPrefix}map/wmts/capabilities`, AuthMiddleware.requireMapUsePermission, async (req, res, next) => { diff --git a/server/modules/record/manager/_recordManager/recordValidationManager.js b/server/modules/record/manager/_recordManager/recordValidationManager.js index f04f7417b2..1479f4a529 100644 --- a/server/modules/record/manager/_recordManager/recordValidationManager.js +++ b/server/modules/record/manager/_recordManager/recordValidationManager.js @@ -46,7 +46,7 @@ export const validateNodesAndPersistValidation = async (survey, record, nodes, v // 1. validate node values const nodesValueValidation = await RecordValidator.validateNodes({ survey, record, nodes }) const nodesValueValidationsByUuid = Validation.getFieldValidations(nodesValueValidation) - // 1.a. workaraound: always define value field validation even when validation is valid to allow cleaning up errors later + // 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]) => { if (Validation.isValid(nodeValueValidation)) { const nodeValueValidationUpdated = Validation.setField('value', Validation.newInstance())(nodeValueValidation) diff --git a/server/modules/record/service/recordService.js b/server/modules/record/service/recordService.js index aa3cf99858..3fa761458b 100644 --- a/server/modules/record/service/recordService.js +++ b/server/modules/record/service/recordService.js @@ -416,7 +416,7 @@ export const generateNodeFileNameForDownload = async ({ surveyId, nodeUuid, file const keyValuesFormatted = ancestorKeyDefs.map((keyDef, index) => { const keyValue = ancestorKeyValues[index] const keyValueFormatted = NodeValueFormatter.format({ survey, nodeDef: keyDef, value: keyValue }) - return keyValueFormatted + return encodeURIComponent(keyValueFormatted) }) fileNameParts.unshift(keyValuesFormatted.join('_')) } diff --git a/server/modules/survey/service/publish/jobs/chainsCyclesCheckJob.js b/server/modules/survey/service/publish/jobs/chainsCyclesCheckJob.js index e6769f78b9..cff1a0ab10 100644 --- a/server/modules/survey/service/publish/jobs/chainsCyclesCheckJob.js +++ b/server/modules/survey/service/publish/jobs/chainsCyclesCheckJob.js @@ -6,7 +6,6 @@ import * as Survey from '@core/survey/survey' import * as SurveyManager from '@server/modules/survey/manager/surveyManager' import * as NodeDefManager from '@server/modules/nodeDef/manager/nodeDefManager' -import * as AnalysisManager from '@server/modules/analysis/manager' export default class ChainsCyclesCheckJob extends Job { constructor(params) { @@ -14,23 +13,10 @@ export default class ChainsCyclesCheckJob extends Job { } async execute() { - this.total = 3 - const { surveyId, tx } = this - const { cycleKeys, cycleKeysDeleted = [] } = await this._getCycleKeys() + const { cycleKeys } = await this._getCycleKeys() // 1. set all survey cycles to all nodeDef analysis await NodeDefManager.updateNodeDefAnalysisCycles(surveyId, cycleKeys, tx) - this.incrementProcessedItems() - - // 2. dissoc deleted survey cycles from processing chains - if (!R.isEmpty(cycleKeysDeleted)) { - await AnalysisManager.removeChainCycles({ surveyId, cycles: cycleKeysDeleted }, tx) - } - this.incrementProcessedItems() - - // 3. delete processing chains with no cycles - await AnalysisManager.deleteChainWithoutCycle({ surveyId }, tx) - this.incrementProcessedItems() } async _getCycleKeys() { diff --git a/server/utils/exportFileNameGenerator.js b/server/utils/exportFileNameGenerator.js index 9403b94a0c..5d5c3c050e 100644 --- a/server/utils/exportFileNameGenerator.js +++ b/server/utils/exportFileNameGenerator.js @@ -5,11 +5,20 @@ import { RecordCycle } from '@core/record/recordCycle' import * as DateUtils from '@core/dateUtils' const generate = ({ survey, fileType, cycle, itemName = null, extension = 'csv', includeTimestamp = false }) => { - const surveyName = Survey.getName(survey) - const cyclePart = Objects.isEmpty(cycle) ? '' : `_${`(cycle-${RecordCycle.getLabel(cycle)})`}` - const itemNamePart = itemName ? `_${itemName}` : '' - const timestampPart = includeTimestamp ? `_${DateUtils.nowFormatDefault()}` : '' - return `${surveyName}${cyclePart}${itemNamePart}_${fileType}${timestampPart}.${extension}` + const parts = [Survey.getName(survey)] + + if (Objects.isNotEmpty(cycle)) { + parts.push(`(cycle-${RecordCycle.getLabel(cycle)})`) + } + if (Objects.isNotEmpty(itemName)) { + parts.push(encodeURIComponent(itemName)) + } + parts.push(fileType) + + if (includeTimestamp) { + parts.push(DateUtils.nowFormatDefault()) + } + return `${parts.join('_')}.${extension}` } export const ExportFileNameGenerator = { diff --git a/test/e2e/tests/_nodeDefDetails/editExpression.js b/test/e2e/tests/_nodeDefDetails/editExpression.js index 487294f268..12815b696d 100644 --- a/test/e2e/tests/_nodeDefDetails/editExpression.js +++ b/test/e2e/tests/_nodeDefDetails/editExpression.js @@ -3,14 +3,18 @@ import { TestId, getSelector } from '../../../../webapp/utils/testId' import { FormUtils } from '../utils/formUtils' import { persistNodeDefChanges } from './editDetails' +const advancedTextEditorSelector = '.cm-content' + const editAdvanced = async (expressionStr) => { await page.click(getSelector(TestId.expressionEditor.toggleModeBtn, 'button')) // wait for codemirror to initialize await page.waitForTimeout(500) - const codeMirror = await page.waitForSelector('.CodeMirror') + await page.waitForSelector(advancedTextEditorSelector) + const textEditorLocator = page.locator(advancedTextEditorSelector) + await textEditorLocator.click() await each(expressionStr, async (char) => { - await codeMirror.press(char) + await textEditorLocator.press(char) }) } diff --git a/webapp/components/Map/Map.js b/webapp/components/Map/Map.js index d7a720e0b7..0dfbab0323 100644 --- a/webapp/components/Map/Map.js +++ b/webapp/components/Map/Map.js @@ -1,7 +1,7 @@ import './Map.scss' import React from 'react' -import { MapContainer, ScaleControl } from 'react-leaflet' +import { GeoJSON, MapContainer, ScaleControl } from 'react-leaflet' import PropTypes from 'prop-types' import { ButtonSave } from '@webapp/components' @@ -32,7 +32,7 @@ L.Marker.prototype.options.icon = L.icon({ const INITIAL_ZOOM_LEVEL = 3 export const Map = (props) => { - const { editable = false, layers = [], markerPoint, markerTitle, showOptions = true } = props + const { editable = false, geoJson = null, layers = [], markerPoint, markerTitle, showOptions = true } = props const { centerPositionLatLon, markerPointUpdated, markerPointUpdatedToString, onMarkerPointUpdated, onSaveClick } = useMap(props) @@ -54,6 +54,7 @@ export const Map = (props) => { onPointUpdated={onMarkerPointUpdated} title={markerTitle} /> + {geoJson && } {showOptions && ( <> @@ -85,6 +86,7 @@ export const Map = (props) => { Map.propTypes = { centerPoint: PropTypes.object, editable: PropTypes.bool, + geoJson: PropTypes.object, layers: PropTypes.array, markerPoint: PropTypes.object, markerTitle: PropTypes.string, diff --git a/webapp/components/ResizableModal/ResizableModal.js b/webapp/components/ResizableModal/ResizableModal.js index 4c37320365..af8a0ae10d 100644 --- a/webapp/components/ResizableModal/ResizableModal.js +++ b/webapp/components/ResizableModal/ResizableModal.js @@ -39,7 +39,15 @@ export const ResizableModal = (props) => { return (
- +

{header}

{onDetach && ( @@ -68,6 +76,6 @@ ResizableModal.propTypes = { left: PropTypes.number, onClose: PropTypes.func, onDetach: PropTypes.func, - onRequestClose: PropTypes.func, + onRequestClose: PropTypes.func.isRequired, top: PropTypes.number, } diff --git a/webapp/components/TreeView/TreeView.js b/webapp/components/TreeView/TreeView.js index c48ef129b4..1b3a447cd2 100644 --- a/webapp/components/TreeView/TreeView.js +++ b/webapp/components/TreeView/TreeView.js @@ -5,9 +5,15 @@ import PropTypes from 'prop-types' import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView' import { TreeItem as MuiTreeItem } from '@mui/x-tree-view/TreeItem' +import { Objects } from '@openforis/arena-core' + +import { ArrayUtils } from '@core/arrayUtils' + +import { LabelWithTooltip } from '../form/LabelWithTooltip' const TreeItemPropTypes = PropTypes.shape({ key: PropTypes.string.isRequired, + icon: PropTypes.any, items: PropTypes.array, label: PropTypes.string.isRequired, testId: PropTypes.string, @@ -15,9 +21,19 @@ const TreeItemPropTypes = PropTypes.shape({ const TreeItemView = (props) => { const { item } = props - const { key, label, items, testId } = item + const { key, icon, label, items, testId } = item return ( - + + {icon} + +
+ } + data-testid={testId} + > {items?.map((childItem) => ( ))} @@ -43,7 +59,8 @@ export const TreeView = (props) => { const treeItemKeysBeingCollapsed = expadedItemKeys.filter( (oldExpandedItemId) => !itemIds.includes(oldExpandedItemId) ) - if (treeItemKeysBeingCollapsed.length > 0 && event?.target?.className === 'MuiTreeItem-label') { + const targetClass = String(event?.target?.className) + if (treeItemKeysBeingCollapsed.length > 0 && targetClass.includes('label')) { // do not collapse item if it is expanded and label is clicked; handle only selection; return false } @@ -54,9 +71,11 @@ export const TreeView = (props) => { const onSelectedItemsChange = useCallback( (_event, itemIds) => { - onSelectedItemKeysChange(itemIds) + if (!Objects.isEqual(selectedItemKeys, ArrayUtils.toArray(itemIds))) { + onSelectedItemKeysChange(itemIds) + } }, - [onSelectedItemKeysChange] + [onSelectedItemKeysChange, selectedItemKeys] ) return ( diff --git a/webapp/components/buttons/ButtonNext.js b/webapp/components/buttons/ButtonNext.js index 2b4627cebd..9ca7a0972e 100644 --- a/webapp/components/buttons/ButtonNext.js +++ b/webapp/components/buttons/ButtonNext.js @@ -4,7 +4,7 @@ import { Button } from './Button' export const ButtonNext = (props) => { const { label = 'common.next' } = props - return