/*
* Copyright 2024 Anyware Services
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A mixin to have methods for widgets (such as relative fields...)
*/
Ext.define('Ametys.form.Widget', {
statics: {
/**
* Get the value of another item
* @param {String} relativePath The relative path to another value
* @param {Ext.form.Field/Object} data An extjs field when in a ConfigurableFormPanel or an object with 'record' and 'dataPath'
* @param {Object} options An object that can contain options about how to get the value (such as "silently")
* @return {Object} The value of the field. Can be null. undefined if there is no such field.
*/
getRelativeValue(relativePath, data, options) {
options = options || {};
if (!data)
{
return null;
}
else if (data.isComponent)
{
// Configurable Form Panel
let form = Ametys.form.Widget._getFormFromField(data);
let referencedField = form.getRelativeField(relativePath, data, options.silently);
return referencedField ? referencedField.getValue() : undefined;
}
else if (data.record && data.dataPath)
{
const SEPARATOR = "/";
let resolvedPathAndRecord = this._resolveParentReferencesInRelativePath(data.dataPath, relativePath, data.record);
let valuePath = resolvedPathAndRecord.path;
let cursorData = resolvedPathAndRecord.record.data;
// Split on /
let paths = valuePath.split(SEPARATOR);
let acumulator = "";
for (let path of paths)
{
if (acumulator)
{
acumulator += SEPARATOR;
}
acumulator += path;
if (acumulator.endsWith("]"))
{
// repeater
let i = acumulator.indexOf("[");
let index = parseInt(acumulator.substring(i + 1, acumulator.length - 1));
cursorData = cursorData[acumulator.substring(0, i) + "_repeater"].entries[index - 1].values;
acumulator = "";
}
}
let relativeValue = cursorData[acumulator];
if (options.silently !== true && relativeValue === undefined)
{
var message = "{{i18n PLUGINS_CORE_UI_WIDGET_UNKNOWN_FIELD}}" + valuePath;
this.getLogger().warn(message);
}
return relativeValue;
}
else
{
throw new Error("getRelativeValue not supported for " + data);
}
},
/**
* Set the value at the dataPath
* @param {String} valuePath The path to the value to change
* @param {Ext.data.Model} record The record to change
* @param {Object} value the new value
*/
setRecordValue: function(valuePath, record, value)
{
const SEPARATOR = "/";
let cursorData = record.data;
// Split on /
let paths = valuePath.split(SEPARATOR);
let acumulator = "";
let firstRepeaterAcumulator;
for (let path of paths)
{
if (acumulator)
{
acumulator += SEPARATOR;
}
acumulator += path;
if (acumulator.endsWith("]"))
{
// repeater
let i = acumulator.indexOf("[");
let index = parseInt(acumulator.substring(i + 1, acumulator.length - 1));
let pointer = acumulator.substring(0, i) + "_repeater";
if (!firstRepeaterAcumulator)
{
firstRepeaterAcumulator = pointer;
}
cursorData = cursorData[pointer].entries[index - 1].values;
acumulator = "";
}
}
if (firstRepeaterAcumulator)
{
cursorData[acumulator] = value;
record.set(firstRepeaterAcumulator.substring(0, firstRepeaterAcumulator.length - "_repeater".length), record.get(firstRepeaterAcumulator));
}
else
{
record.set(acumulator, value);
}
},
/**
* Get the values corresponding the model path. Will seek in repeaters.
* @param {Ext.data.Model} record The record to analyse
* @param {String} modelPath The model path to consider
* @return {Object} An object where id is the dataPath and value the corresponding value
*/
getRecordValues: function(record, modelPath)
{
let values = {};
const SEPARATOR = "/";
// Split on /
let paths = modelPath.split(SEPARATOR);
seek(paths, 0, "", "", record.data, record.data);
function seek(paths, index, totalAccumulator, accumulator, cursorData)
{
let path = paths[index];
if (accumulator)
{
accumulator += SEPARATOR;
}
if (totalAccumulator)
{
totalAccumulator += SEPARATOR;
}
accumulator += path;
totalAccumulator += path;
if (accumulator.endsWith("]"))
{
// repeater entry
let i = accumulator.indexOf("[");
let index = parseInt(accumulator.substring(i + 1, accumulator.length - 1));
seek(paths, index + 1, totalAccumulator, "" , cursorData[accumulator.substring(0, i) + "_repeater"].entries[index - 1].values);
}
else if (cursorData[accumulator + "_repeater"])
{
// repeater loop
let entries = cursorData[accumulator + "_repeater"].entries;
for (let entry of entries)
{
seek(paths, index + 1, totalAccumulator + "[" + entry.position + "]", "", entry.values)
}
}
else if (index < paths.length - 1)
{
seek(paths, index+1, totalAccumulator, accumulator, cursorData)
}
else
{
values[totalAccumulator] = cursorData[accumulator];
}
}
return values;
},
/**
* Register a listener when other values changed
* @param {String/String[]} relativePaths The relative paths to other values
* @param {Ext.form.Field/Object} data An extjs field when in a ConfigurableFormPanel or an object with 'grid' and 'dataPath'
* @param {Function} handler The on change handler
* @param {String} handler.relativePath The relative path to the value that has triggered the on change event.
* @param {Ext.form.Field/Object} handler.data The data
* @param {Object} handler.newValue The new value
* @param {Object} handler.oldValue The old value
* @param {Object} scope The scope handler. Default to the field or data.grid.
*/
onRelativeValueChange: function(relativePaths, data, handler, scope)
{
const SEPARATOR = "/";
relativePaths = Ext.Array.from(relativePaths);
if (!data)
{
throw new Error("Cannot register a relative value change on a null component");
}
else if (data.isComponent)
{
let form = Ametys.form.Widget._getFormFromField(data);
for (let relativePath of relativePaths)
{
// Configurable Form Panel
function proxyHandler(field, newValue, oldValue)
{
// do not simple call handler() to keep the 'this' scope
handler.call(this, relativePath, data, newValue, oldValue);
}
form.onRelativeFieldsChange(relativePath, data, proxyHandler, scope);
}
}
else if (data.grid && data.dataPath)
{
for (let relativePath of relativePaths)
{
let parentPath = data.dataPath.substring(0, data.dataPath.lastIndexOf(SEPARATOR));
let valuePath = this._resolveParentReferences(parentPath, relativePath);
data.grid.changeListeners = data.grid.changeListeners || {};
data.grid.changeListeners[valuePath] = data.grid.changeListeners[valuePath] || [];
data.grid.changeListeners[valuePath].push(function(localData, newValue, oldValue) {
handler.call(this, relativePath, {grid: data.grid, dataPath: data.dataPath, record: localData.record}, newValue, oldValue);
});
}
}
else
{
throw new Error("onRelativeValueChange not supported for " + data);
}
},
/**
* Evaluate the disable conditions of the field now
* @param {Ext.form.Field/Object} data An extjs field or the object with {disableCondition, dataPath and record}
* @return {Boolean} Should disable?
*/
evaluateDisableCondition: function(data)
{
if (data.disableCondition === 'string')
{
data.disableCondition = JSON.parse(data.disableCondition, data);
}
return Ametys.form.Widget._evaluateDisableCondition(data.disableCondition, data).disabled;
},
/**
* @private
* Evaluates the disable condition when a matching field is changing and enables/disables the field accordingly.
* @param {Object} disableCondition the disable condition.
* @param {Ext.form.Field/Object} data An extjs field or the object with grid info
* @return {Object} the result of the given disable condition
* @return {Boolean} return.disabled true if the disable condition is verified, false otherwise.
* @return {Boolean} return.hidden true if the field must be hidden due to one verified condition
*/
_evaluateDisableCondition: function(disableCondition, data)
{
const EXTERNAL_CONDITION_PREFIX = "__external";
if (!disableCondition || !disableCondition.conditions && !disableCondition.condition)
{
return {
disabled: false,
hidden: false
};
}
let disable = disableCondition['type'] != "and" ? false : true;
let hidden = false;
if (disableCondition.conditions)
{
var conditionsList = disableCondition.conditions,
conditionsListLength = conditionsList.length;
for (var i = 0; i < conditionsListLength; i++)
{
var result = Ametys.form.Widget._evaluateDisableCondition(conditionsList[i], data);
disable = disableCondition['type'] != "and" ? disable || result.disabled : disable && result.disabled;
hidden = hidden || result.hidden;
}
}
if (disableCondition.condition)
{
var conditionList = disableCondition.condition,
conditionListLength = conditionList.length;
for (var i = 0; i < conditionListLength; i++)
{
var id = conditionList[i]['id'],
op = conditionList[i]['operator'],
val = conditionList[i]['value'],
hideIfDisabled = conditionList[i]['hideIfDisabled'];
var result = Ametys.form.Widget._evaluateCondition(id, op, val, data);
disable = disableCondition['type'] != "and" ? disable || result : disable && result;
if (result && (id.startsWith(EXTERNAL_CONDITION_PREFIX) || hideIfDisabled))
{
hidden = true;
}
}
}
return {
disabled: disable,
hidden: hidden
};
},
/**
* @private
* Evaluates a single condition.
* @param {String} id the id of the field.
* @param {String} operator the operator.
* @param {String} untypedValue the untyped value the field's value will be compared to.
* @param {Ext.form.Field/Object} data An extjs field or the object with grid info
* @return {Boolean} result true if the condition is verified, false otherwise.
*/
_evaluateCondition: function(id, operator, untypedValue, data)
{
let type;
const EXTERNAL_CONDITION_PREFIX = "__external";
if (data.isComponent)
{
let form = Ametys.form.Widget._getFormFromField(data);
if (id.startsWith(EXTERNAL_CONDITION_PREFIX))
{
let fieldName = (data.isRepeater ? data.prefix : '') + data.name;
let dataName = this._getDataName(form, fieldName);
let completeId = id + "_" + dataName;
let externalDisableConditionsValues = this._getExternalDisableConditionsValuesFromForm(form, fieldName);
return externalDisableConditionsValues.hasOwnProperty(completeId)
? externalDisableConditionsValues[completeId]
: false;
}
let formField = form.getRelativeField(id, data);
if (!formField)
{
return false; // no field, not disabled...
}
type = formField.type;
}
else if (data.record)
{
let record = data.record;
if (id.startsWith(EXTERNAL_CONDITION_PREFIX))
{
let externalDisableConditionsValues = Ametys.form.Widget._getExternalDisableConditionsValuesFromRecord(record);
let completeId = id + "_" + data.dataPath;
return externalDisableConditionsValues && externalDisableConditionsValues.hasOwnProperty(completeId)
? externalDisableConditionsValues[completeId]
: false;
}
let resolvedPathAndRecord = this._resolveParentReferencesInRelativePath(data.dataPath, id, record);
let localId = resolvedPathAndRecord.path;
record = resolvedPathAndRecord.record;
let field = record.getField(localId);
if (!field)
{
return false; // no field, not disabled...
}
type = field.ftype || field.type;
}
else
{
throw new Error("Disable conditions not supported for " + data);
}
var fieldValue = Ametys.form.Widget.getRelativeValue(id, data);
fieldValue = Ametys.form.widget.Externalizable.getValueInUse(fieldValue);
var typedValue = Ametys.form.Widget._convertUntypedValue(type, untypedValue);
if (fieldValue === undefined || fieldValue === "")
{
fieldValue = null;
}
if (typedValue === undefined || typedValue === "")
{
typedValue = null;
}
switch (operator)
{
case "gt" :
if (!Ext.isArray(fieldValue))
{
return fieldValue > typedValue;
}
else
{
// All entries need to match operator
for (var i = 0; i < fieldValue.length; i++)
{
if (!(fieldValue[i] > typedValue))
{
// One entry does not match!
return false;
}
}
return true;
}
case "geq" :
if (!Ext.isArray(fieldValue))
{
return fieldValue >= typedValue;
}
else
{
// All entries need to match operator
for (var i = 0; i < fieldValue.length; i++)
{
if (!(fieldValue[i] >= typedValue))
{
// One entry does not match!
return false;
}
}
return true;
}
case "lt" :
if (!Ext.isArray(fieldValue))
{
return fieldValue < typedValue;
}
else
{
// All entries need to match operator
for (var i = 0; i < fieldValue.length; i++)
{
if (!(fieldValue[i] < typedValue))
{
// One entry does not match!
return false;
}
}
return true;
}
case "leq" :
if (!Ext.isArray(fieldValue))
{
return fieldValue <= typedValue;
}
else
{
// All entries need to match operator
for (var i = 0; i < fieldValue.length; i++)
{
if (!(fieldValue[i] <= typedValue))
{
// One entry does not match!
return false;
}
}
return true;
}
case "neq" :
if (!Ext.isArray(fieldValue))
{
return fieldValue !== typedValue; // null and empty string should be considered the same
}
else
{
// All entries need to match operator
for (var i = 0; i < fieldValue.length; i++)
{
if (!(fieldValue[i] !== typedValue))
{
// One entry does not match!
return false;
}
}
return true;
}
case "eq" :
if (!Ext.isArray(fieldValue))
{
return fieldValue === typedValue;
}
else
{
// One entry needs to match operator
for (var i = 0; i < fieldValue.length; i++)
{
if (fieldValue[i] === typedValue)
{
// One entry does match!
return true;
}
}
return false;
}
default :
throw "Unknown operator " + operator;
}
},
shouldHideDisabledField: function(data)
{
// First check on the data if the disabled behavior is forced
if (data.disabledItemRendering == "hidden")
{
return true;
}
else if (data.disabledItemRendering == "disabled")
{
return false;
}
// Then, check if the hide option is set to true on form
else if (data.isComponent && Ametys.form.Widget._getFormFromField(data).hideDisabledFields)
{
return true;
}
// In the end, check if there is a verified condition configured to hide the field
if (data.disableCondition === 'string')
{
data.disableCondition = JSON.parse(data.disableCondition, data);
}
return Ametys.form.Widget._evaluateDisableCondition(data.disableCondition, data).hidden;
},
/**
* @private
* Retrieves the name of the data that will be used as suffix in condition identifier
* @param {{Ext.form.Panel} form the form
* @param {String} fieldName the name of the field with the condition
* @return {String} the name of the data that will be used as suffix in condition identifier
*/
_getDataName: function(form, fieldName)
{
if (fieldName.includes("["))
{
let pathSegments = fieldName.split(form.defaultPathSeparator);
let indexOfRepeater = this._getLastRepeaterIndex(pathSegments);
let dataName = '';
for (let i = indexOfRepeater + 1; i < pathSegments.length; i++)
{
if (i > indexOfRepeater + 1)
{
dataName += form.defaultPathSeparator;
}
dataName += pathSegments[i];
}
return dataName;
}
else
{
return fieldName.startsWith(form.fieldNamePrefix)
? fieldName.substring(form.fieldNamePrefix.length)
: fieldName;
}
},
/**
* @private
* Retrieves the external disable condition values from the given form
* @param {{Ext.form.Panel} form the form
* @param {String} fieldName the name of the field with the condition
* @return {Object} the external disable condition values from the given form
*/
_getExternalDisableConditionsValuesFromForm: function(form, fieldName)
{
if (fieldName.includes("["))
{
let startsWithPrefix = fieldName.startsWith(form.fieldNamePrefix);
let fieldNameWithoutPrefix = startsWithPrefix
? fieldName.substring(form.fieldNamePrefix.length)
: fieldName;
let pathSegments = fieldNameWithoutPrefix.split(form.defaultPathSeparator);
let indexOfRepeater = this._getLastRepeaterIndex(pathSegments);
let repeaterSegment = pathSegments[indexOfRepeater];
let repeaterName = repeaterSegment.substring(0, repeaterSegment.lastIndexOf("["));
let repeaterItemPosition = parseInt(repeaterSegment.substring(repeaterSegment.lastIndexOf("[") + 1, repeaterSegment.lastIndexOf("]")));
let repeaterPrefix = startsWithPrefix ? form.fieldNamePrefix : '';
for (let i = 0; i < indexOfRepeater; i++)
{
repeaterPrefix += pathSegments[i] + form.defaultPathSeparator;
}
let repeater = form.getRepeaters().filter(function(r) {
return r.name == repeaterName && r.prefix == repeaterPrefix;
})[0];
return repeater.getItemExternalDisableConditionsValues(repeaterItemPosition - 1);
}
else
{
return form.getExternalDisableConditionsValues();
}
},
/**
* @private
* Retrieves the index of the last segment representing a repeater entry
* @param {String[]} pathSegments the path segments
* @return {Object} the index of the last segment representing a repeater entry
*/
_getLastRepeaterIndex(pathSegments)
{
let indexOfRepeater = pathSegments.length - 1;
let pathSegment = pathSegments[indexOfRepeater];
while (indexOfRepeater > -1 && !pathSegment.includes("["))
{
indexOfRepeater--;
pathSegment = pathSegments[indexOfRepeater]
}
return indexOfRepeater;
},
/**
* @private
* Retrieves the external disable conditions values from the given record
* @param {Ext.data.Model} record the record
* @return the external disable conditions values from the given record
*/
_getExternalDisableConditionsValuesFromRecord: function(record)
{
let externalDisableConditionsValues = record.get("__externalDisableConditionsValues");
if (externalDisableConditionsValues === undefined)
{
// If not found in the record, it may be in the temporary data used by ContentViewTreeGridPanel
// This can happen at content grid loading
let data = record.get("data");
if (data && data.hasOwnProperty("__externalDisableConditionsValues"))
{
externalDisableConditionsValues = data["__externalDisableConditionsValues"];
}
}
if (externalDisableConditionsValues === undefined)
{
// If not found, it may be in properties
let properties = record.get("properties");
if (properties && properties.hasOwnProperty("__externalDisableConditionsValues"))
{
externalDisableConditionsValues = properties["__externalDisableConditionsValues"];
}
}
if (externalDisableConditionsValues === undefined)
{
// If not found, it may be in parent record
let parentRecord = Ametys.plugins.cms.search.SearchGridHelper._getParentRecord(record);
if (parentRecord)
{
externalDisableConditionsValues = Ametys.form.Widget._getExternalDisableConditionsValuesFromRecord(parentRecord);
}
}
return externalDisableConditionsValues;
},
/**
* @private
* Resolve the parent references (..) in the relative path. Retrieves the resolved path and the relative record
* ex1: content of type exhaustive, record represents the content, the data path is composite/repeater/comp-string, the relative path is ../../boolean
* the returned path is boolean, and record represents the content
* ex2: content of type exhaustive, record represents the composite/repeater, the data path is comp-string, the relative path is ../../boolean
* the returned path is boolean, and record represents the content
* ex2: content of type exhaustive, record represents the repeaterThreeLevels/repeater2/repeater3, the data path is rep3-string, the relative path is ../../rep-boolean
* the returned path is rep-boolean, and record represents the repeater of first level
* @param {String} dataPath the path of the data relative to the given record
* @param {String} relativePath the path relative to base data path
* @param {Ext.data.Model} record the record containing the data at the given base path
* @return {Object} an object containing the absolute path (absolutePath) and the record containing the data at the absolute path (record)
*/
_resolveParentReferencesInRelativePath: function (dataPath, relativePath, record)
{
const SEPARATOR = "/";
let localPath = relativePath;
let localRecord = record;
if (localPath.startsWith('..' + SEPARATOR))
{
let parentPath = Ametys.plugins.cms.search.SearchGridHelper._getParentMetadataPath(localRecord);
if (parentPath)
{
localRecord = Ametys.plugins.cms.search.SearchGridHelper._getParentRecord(localRecord);
// Compute the parent path, relative to the parent record
// ex: parentPath is repeater1/composite1/repeater2, parent record is repeater1, relativeParentPath is composite1/repeater2
let parentParentPath = Ametys.plugins.cms.search.SearchGridHelper._getParentMetadataPath(localRecord);
let relativeParentPath = parentParentPath ? parentPath.substring(parentParentPath.length + SEPARATOR.length) : parentPath;
localPath = this._resolveParentReferences(relativeParentPath, localPath);
return this._resolveParentReferencesInRelativePath(dataPath, localPath, localRecord);
}
else
{
// No parent data path => localRecord is root
if (dataPath.includes(SEPARATOR))
{
let parentPath = dataPath.substring(0, dataPath.lastIndexOf(SEPARATOR))
localPath = this._resolveParentReferences(parentPath, localPath);
}
}
}
return {
path: localPath,
record: localRecord
};
},
/**
* @private
* Resolve the parent references(..) in the given relativePath
* The parent references are removed and necessary parent path segment are added to the retrieved path
* @param {String} parentPath the parent path of the data relative to the relative path
* @param {String} relativePath the path relative to base data path
* @return {String} the relative path with resolved parent references
*/
_resolveParentReferences: function(parentPath, relativePath)
{
const SEPARATOR = "/";
let localPath = relativePath;
let parentPathSegments = parentPath.split(SEPARATOR);
for (let i = parentPathSegments.length - 1 ; i >= 0 ; i--)
{
if (localPath.startsWith('..' + SEPARATOR))
{
localPath = localPath.substring(('..' + SEPARATOR).length);
}
else
{
localPath = parentPathSegments[i] + SEPARATOR + localPath;
}
}
return localPath;
},
/**
* @private
* Converts an untyped value to a typed value according the given type
* @param {String} fieldType The type to convert into
* @param {String} untypedValue The untyped value, as a String
* @return The typed value
*/
_convertUntypedValue: function (fieldType, untypedValue)
{
switch (fieldType) {
case "boolean":
if (Ext.isString(untypedValue))
{
return untypedValue.toLowerCase() === "true"
}
else if (untypedValue === undefined || untypedValue === null)
{
return untypedValue;
}
return !!untypedValue;
case "long":
return parseInt(untypedValue);
case "double":
return parseFloat(untypedValue);
case "date":
return Ext.Date.parse(untypedValue, Ext.Date.patterns.ISO8601DateTime);
default:
return untypedValue;
}
},
/**
* @private
* Retrieves the form from the given field
* @param {Ext.form.Field} field The extjs field
* @return the form containing the given field
*/
_getFormFromField: function(field)
{
let form = field.form;
if (!form)
{
// repeater for example
form = field;
while (!form.isConfigurableFormPanel)
{
form = form.ownerCt;
}
}
return form;
},
/**
* @public
* Creates a disble condition with given options and add it to the given field
* @param {Object} field the field that will have the new condition
* @param {String} conditionId The id of condition (the path to the referenced field in case of relative condition)
* @param {String} operator the condition's operator
* @param {String} value The value to check for the condition
* @param {Boolean} hideIfDisabled true if the field must be hidden when the new condition is verified. Default to false
* @param {String} type type of disabled conditions that will wrap the existent conditions and the new one. Can be 'or' or 'and'. Default to 'or'
* @return {Object} The object for the disable condition
*/
addDisableCondition: function(field, conditionId, operator, value, hideIfDisabled, type)
{
let newCondition;
let existingCondition = field['disableCondition'];
if (!Ext.Object.isEmpty(existingCondition) && existingCondition.condition)
{
newCondition = {
type: type || 'or',
condition: [{
id: conditionId,
operator: operator,
value: value,
hideIfDisabled: hideIfDisabled || false
}],
conditions: [existingCondition]
}
}
else
{
newCondition = {
condition: [{
id: conditionId,
operator: operator,
value: value,
hideIfDisabled: hideIfDisabled || false
}]
};
}
field['disableCondition'] = newCondition;
},
}
});