389 lines
11 KiB
JavaScript
389 lines
11 KiB
JavaScript
/////////////////////////////////////////////////////////////
|
|
//
|
|
// pgAdmin 4 - PostgreSQL Tools
|
|
//
|
|
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
|
|
// This software is released under the PostgreSQL Licence
|
|
//
|
|
//////////////////////////////////////////////////////////////
|
|
|
|
import diffArray from 'diff-arrays-of-objects';
|
|
import _ from 'lodash';
|
|
|
|
import gettext from 'sources/gettext';
|
|
import { memoizeFn } from 'sources/utils';
|
|
import {
|
|
minMaxValidator, numberValidator, integerValidator, emptyValidator,
|
|
checkUniqueCol, isEmptyString
|
|
} from 'sources/validators';
|
|
|
|
import BaseUISchema from '../base_schema.ui';
|
|
import { isModeSupportedByField, isObjectEqual, isValueEqual } from '../common';
|
|
|
|
|
|
export const SCHEMA_STATE_ACTIONS = {
|
|
INIT: 'init',
|
|
SET_VALUE: 'set_value',
|
|
ADD_ROW: 'add_row',
|
|
DELETE_ROW: 'delete_row',
|
|
MOVE_ROW: 'move_row',
|
|
RERENDER: 'rerender',
|
|
CLEAR_DEFERRED_QUEUE: 'clear_deferred_queue',
|
|
DEFERRED_DEPCHANGE: 'deferred_depchange',
|
|
BULK_UPDATE: 'bulk_update',
|
|
};
|
|
|
|
// Remove cid key added by prepareData
|
|
const cleanCid = (coll, keepCid=false) => (
|
|
(!coll || keepCid) ? coll : coll.map(
|
|
(o) => _.pickBy(o, (v, k) => (k !== 'cid'))
|
|
)
|
|
);
|
|
|
|
|
|
export function getCollectionDiffInEditMode(
|
|
field, origVal, sessVal, keepCid, parseChanges
|
|
) {
|
|
let change = {};
|
|
|
|
const id = field.id;
|
|
const collIdAttr = field.schema.idAttribute;
|
|
const origColl = _.get(origVal, id) || [];
|
|
const sessColl = _.get(sessVal, id) || [];
|
|
/*
|
|
* Use 'diffArray' package to get the array diff and extract the
|
|
* info. 'cid' attribute is used to identify the rows uniquely.
|
|
*/
|
|
const changeDiff = diffArray(
|
|
origColl, sessColl || [], 'cid', { compareFunction: isObjectEqual }
|
|
);
|
|
|
|
if(changeDiff.added.length > 0) {
|
|
change['added'] = cleanCid(changeDiff.added, keepCid);
|
|
}
|
|
|
|
if(changeDiff.removed.length > 0) {
|
|
change['deleted'] = cleanCid(changeDiff.removed.map((row) => {
|
|
// Deleted records must be from the original data, not the newly added.
|
|
return _.find(_.get(origVal, field.id), ['cid', row.cid]);
|
|
}), keepCid);
|
|
}
|
|
|
|
if(changeDiff.updated.length > 0) {
|
|
/*
|
|
* There is a change in collection. Parse further deep to figure
|
|
* out the exact details.
|
|
*/
|
|
let changed = [];
|
|
|
|
for(const changedRow of changeDiff.updated) {
|
|
const rowIndxSess = _.findIndex(
|
|
_.get(sessVal, id), (row) => (row.cid === changedRow.cid)
|
|
);
|
|
const rowIndxOrig = _.findIndex(
|
|
_.get(origVal, id), (row) => (row.cid==changedRow.cid)
|
|
);
|
|
const finalChangedRow = parseChanges(
|
|
field.schema, _.get(origVal, [id, rowIndxOrig]),
|
|
_.get(sessVal, [id, rowIndxSess])
|
|
);
|
|
|
|
if(_.isEmpty(finalChangedRow)) {
|
|
continue;
|
|
}
|
|
|
|
/*
|
|
* If the 'id' attribute value is present, then only changed keys
|
|
* can be passed. Otherwise, passing all the keys is useful.
|
|
*/
|
|
const idAttrValue = _.get(sessVal, [id, rowIndxSess, collIdAttr]);
|
|
|
|
if(_.isUndefined(idAttrValue)) {
|
|
changed.push({ ...changedRow, ...finalChangedRow });
|
|
} else {
|
|
changed.push({ [collIdAttr]: idAttrValue, ...finalChangedRow });
|
|
}
|
|
}
|
|
|
|
if(changed.length > 0) {
|
|
change['changed'] = cleanCid(changed, keepCid);
|
|
}
|
|
}
|
|
|
|
return change;
|
|
}
|
|
|
|
export function getSchemaDataDiff(
|
|
topSchema, initData, sessData, mode, keepCid,
|
|
stringify=false, includeSkipChange=true
|
|
) {
|
|
const isEditMode = mode === 'edit';
|
|
|
|
// This will be executed recursively as data can be nested.
|
|
let parseChanges = (schema, origVal, sessVal) => {
|
|
let levelChanges = {};
|
|
parseChanges.depth =
|
|
_.isUndefined(parseChanges.depth) ? 0 : (parseChanges.depth + 1);
|
|
|
|
/* The comparator and setter */
|
|
const attrChanged = (id, change, force=false) => {
|
|
if(isValueEqual(_.get(origVal, id), _.get(sessVal, id)) && !force) {
|
|
return;
|
|
}
|
|
|
|
change = change || _.get(sessVal, id);
|
|
|
|
if(stringify && (_.isArray(change) || _.isObject(change))) {
|
|
change = JSON.stringify(change);
|
|
}
|
|
|
|
/*
|
|
* Null values are not passed in URL params, pass it as an empty string.
|
|
* Nested values does not need this.
|
|
*/
|
|
if(_.isNull(change) && parseChanges.depth === 0) {
|
|
change = '';
|
|
}
|
|
|
|
levelChanges[id] = change;
|
|
};
|
|
|
|
schema.fields.forEach((field) => {
|
|
// Never include data from the field in the changes, marked as
|
|
// 'excluded'.
|
|
if (field.exclude) return;
|
|
/*
|
|
* If skipChange is true, then field will not be considered for changed
|
|
* data. This is helpful when 'Save' or 'Reset' should not be enabled on
|
|
* this field change alone. No change in other behaviour.
|
|
*/
|
|
if(field.skipChange && !includeSkipChange) return;
|
|
|
|
/*
|
|
* At this point the schema assignments like top may not have been done,
|
|
* so - check if mode is supported by this field, or not.
|
|
*/
|
|
if (!isModeSupportedByField(field, {mode})) return;
|
|
|
|
if(
|
|
typeof(field.type) === 'string' && field.type.startsWith('nested-')
|
|
) {
|
|
/*
|
|
* Even if its nested, state is on same hierarchical level.
|
|
* Find the changes and merge.
|
|
*/
|
|
levelChanges = {
|
|
...levelChanges,
|
|
...parseChanges(field.schema, origVal, sessVal),
|
|
};
|
|
} else if(isEditMode && !_.isEqual(
|
|
_.get(origVal, field.id), _.get(sessVal, field.id)
|
|
)) {
|
|
/*
|
|
* Check for changes only if in edit mode, otherwise - everything can
|
|
* go through comparator
|
|
*/
|
|
if(field.type === 'collection') {
|
|
const change = getCollectionDiffInEditMode(
|
|
field, origVal, sessVal, keepCid, parseChanges
|
|
);
|
|
|
|
if(Object.keys(change).length > 0) {
|
|
attrChanged(field.id, change, true);
|
|
}
|
|
} else {
|
|
attrChanged(field.id);
|
|
}
|
|
} else if(!isEditMode) {
|
|
if(field.type === 'collection') {
|
|
const origColl = _.get(origVal, field.id) || [];
|
|
const sessColl = _.get(sessVal, field.id) || [];
|
|
|
|
let changeDiff = diffArray(
|
|
origColl, sessColl, 'cid', {compareFunction: isObjectEqual}
|
|
);
|
|
|
|
// Check the updated changes,when:
|
|
// 1. These are the fixed rows.
|
|
// 2. 'canReorder' flag is set to true.
|
|
if((
|
|
!_.isUndefined(field.fixedRows) && changeDiff.updated.length > 0
|
|
) || (
|
|
_.isUndefined(field.fixedRows) && (
|
|
changeDiff.added.length > 0 || changeDiff.removed.length > 0 ||
|
|
changeDiff.updated.length > 0
|
|
)
|
|
) || (
|
|
field.canReorder && _.differenceBy(origColl, sessColl, 'cid')
|
|
)) {
|
|
attrChanged(
|
|
field.id, cleanCid(_.get(sessVal, field.id), keepCid), true
|
|
);
|
|
return;
|
|
}
|
|
|
|
if(field.canReorder) {
|
|
changeDiff = diffArray(origColl, sessColl);
|
|
|
|
if(changeDiff.updated.length > 0) {
|
|
attrChanged(
|
|
field.id, cleanCid(_.get(sessVal, field.id), keepCid), true
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
attrChanged(field.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
parseChanges.depth--;
|
|
return levelChanges;
|
|
};
|
|
|
|
let res = parseChanges(topSchema, initData, sessData);
|
|
|
|
return res;
|
|
}
|
|
|
|
export function validateCollectionSchema(
|
|
field, sessData, accessPath, setError
|
|
) {
|
|
const rows = sessData[field.id] || [];
|
|
const currPath = accessPath.concat(field.id);
|
|
|
|
// Loop through data.
|
|
for(const [rownum, row] of rows.entries()) {
|
|
if(validateSchema(
|
|
field.schema, row, setError, currPath.concat(rownum), field.label
|
|
)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Validate duplicate rows.
|
|
const dupInd = checkUniqueCol(rows, field.uniqueCol);
|
|
|
|
if(dupInd > 0) {
|
|
const uniqueColNames = _.filter(
|
|
field.schema.fields, (uf) => field.uniqueCol.indexOf(uf.id) > -1
|
|
).map((uf)=>uf.label).join(', ');
|
|
|
|
if (isEmptyString(field.label)) {
|
|
setError(currPath, gettext('%s must be unique.', uniqueColNames));
|
|
} else {
|
|
setError(
|
|
currPath,
|
|
gettext('%s in %s must be unique.', uniqueColNames, field.label)
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function validateSchema(
|
|
schema, sessData, setError, accessPath=[], collLabel=null
|
|
) {
|
|
sessData = sessData || {};
|
|
|
|
for(const field of schema.fields) {
|
|
// Skip id validation
|
|
if(schema.idAttribute === field.id) {
|
|
continue;
|
|
}
|
|
// If the field is has nested schema, then validate the child schema.
|
|
if(field.schema && (field.schema instanceof BaseUISchema)) {
|
|
if (!field.schema.top) field.schema.top = schema;
|
|
|
|
// A collection is an array.
|
|
if(field.type === 'collection') {
|
|
if (validateCollectionSchema(field, sessData, accessPath, setError))
|
|
return true;
|
|
}
|
|
// A nested schema ? Recurse
|
|
else if(validateSchema(field.schema, sessData, setError, accessPath)) {
|
|
return true;
|
|
}
|
|
} else {
|
|
// Normal field, default validations.
|
|
const value = sessData[field.id];
|
|
|
|
const fieldPath = accessPath.concat(field.id);
|
|
|
|
const setErrorOnMessage = (message) => {
|
|
if (message) {
|
|
setError(fieldPath, message);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if(field.noEmpty) {
|
|
const label = (
|
|
collLabel && gettext('%s in %s', field.label, collLabel)
|
|
) || field.noEmptyLabel || field.label;
|
|
|
|
if (setErrorOnMessage(emptyValidator(label, value)))
|
|
return true;
|
|
}
|
|
|
|
if(field.type === 'int') {
|
|
if (setErrorOnMessage(
|
|
integerValidator(field.label, value) ||
|
|
minMaxValidator(field.label, value, field.min, field.max)
|
|
))
|
|
return true;
|
|
} else if(field.type === 'numeric') {
|
|
if (setErrorOnMessage(
|
|
numberValidator(field.label, value) ||
|
|
minMaxValidator(field.label, value, field.min, field.max)
|
|
))
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return schema.validate(
|
|
sessData, (id, message) => setError(accessPath.concat(id), message)
|
|
);
|
|
}
|
|
|
|
export const getDepChange = (currPath, newState, oldState, action) => {
|
|
if(action.depChange) {
|
|
newState = action.depChange(currPath, newState, {
|
|
type: action.type,
|
|
path: action.path,
|
|
value: action.value,
|
|
oldState: _.cloneDeep(oldState),
|
|
listener: action.listener,
|
|
});
|
|
}
|
|
return newState;
|
|
};
|
|
|
|
// It will help us generating the flat path, and it will return the same
|
|
// object for the same path, which will help with the React componet rendering,
|
|
// as it uses `Object.is(...)` for the comparison of the arguments.
|
|
export const flatPathGenerator = (separator = '.' ) => {
|
|
const flatPathMap = new Map;
|
|
|
|
const setter = memoizeFn((path) => {
|
|
const flatPath = path.join(separator);
|
|
flatPathMap.set(flatPath, path);
|
|
return flatPath;
|
|
});
|
|
|
|
const getter = (flatPath) => {
|
|
return flatPathMap.get(flatPath);
|
|
};
|
|
|
|
return {
|
|
flatPath: setter,
|
|
path: getter,
|
|
// Get the same object every time.
|
|
cached: (path) => (getter(setter(path))),
|
|
};
|
|
};
|