331 lines
8.2 KiB
JavaScript
331 lines
8.2 KiB
JavaScript
/////////////////////////////////////////////////////////////
|
|
//
|
|
// pgAdmin 4 - PostgreSQL Tools
|
|
//
|
|
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
|
|
// This software is released under the PostgreSQL Licence
|
|
//
|
|
//////////////////////////////////////////////////////////////
|
|
|
|
import _ from 'lodash';
|
|
|
|
import { parseApiError } from 'sources/api_instance';
|
|
import gettext from 'sources/gettext';
|
|
|
|
import { prepareData } from '../common';
|
|
import { DepListener } from '../DepListener';
|
|
import { FIELD_OPTIONS, schemaOptionsEvalulator } from '../options';
|
|
|
|
import {
|
|
SCHEMA_STATE_ACTIONS,
|
|
flatPathGenerator,
|
|
getSchemaDataDiff,
|
|
validateSchema,
|
|
} from './common';
|
|
import { createStore } from './store';
|
|
|
|
|
|
export const LOADING_STATE = {
|
|
INIT: 'initialising',
|
|
LOADING: 'loading',
|
|
LOADED: 'loaded',
|
|
ERROR: 'Error'
|
|
};
|
|
|
|
const PATH_SEPARATOR = '/';
|
|
|
|
export class SchemaState extends DepListener {
|
|
constructor(
|
|
schema, getInitData, immutableData, onDataChange, viewHelperProps,
|
|
loadingText
|
|
) {
|
|
super();
|
|
|
|
////// Helper variables
|
|
|
|
// BaseUISchema instance
|
|
this.schema = schema;
|
|
this.viewHelperProps = viewHelperProps;
|
|
// Current mode of operation ('create', 'edit', 'properties')
|
|
this.mode = viewHelperProps.mode;
|
|
// Keep the 'cid' object during diff calculations.
|
|
this.keepcid = viewHelperProps.keepCid;
|
|
// Initialization callback
|
|
this.getInitData = getInitData;
|
|
// Data change callback
|
|
this.onDataChange = onDataChange;
|
|
|
|
////// State variables
|
|
|
|
// Diff between the current snapshot and initial data.
|
|
// Internal state for keeping the changes
|
|
this._changes = {};
|
|
// Current Loading state
|
|
this.loadingState = LOADING_STATE.INIT;
|
|
this.customLoadingText = loadingText;
|
|
|
|
////// Schema instance data
|
|
|
|
// Initial data after the ready state
|
|
this.initData = {};
|
|
|
|
// Immutable data
|
|
this.immutableData = immutableData;
|
|
// Pre-ready queue
|
|
this.preReadyQueue = [];
|
|
|
|
this.optionStore = createStore({});
|
|
this.dataStore = createStore({});
|
|
this.stateStore = createStore({
|
|
isNew: true, isDirty: false, isReady: false,
|
|
isSaving: false, errors: {},
|
|
message: '',
|
|
});
|
|
|
|
// Memoize the path using flatPathGenerator
|
|
this.__pathGenerator = flatPathGenerator(PATH_SEPARATOR);
|
|
|
|
this._id = Date.now();
|
|
}
|
|
|
|
updateOptions() {
|
|
let options = _.cloneDeep(this.optionStore.getState());
|
|
|
|
schemaOptionsEvalulator({
|
|
schema: this.schema, data: this.data, options: options,
|
|
viewHelperProps: this.viewHelperProps,
|
|
});
|
|
|
|
this.optionStore.setState(options);
|
|
}
|
|
|
|
setState(state, value) {
|
|
this.stateStore.set((prev) => _.set(prev, [].concat(state), value));
|
|
}
|
|
|
|
setError(err) {
|
|
this.setState('errors', err);
|
|
}
|
|
|
|
get errors() {
|
|
return this.stateStore.get(['errors']);
|
|
}
|
|
|
|
set errors(val) {
|
|
throw new Error('Property \'errors\' is readonly.', val);
|
|
}
|
|
|
|
get isReady() {
|
|
return this.stateStore.get(['isReady']);
|
|
}
|
|
|
|
setReady(val) {
|
|
this.setState('isReady', val);
|
|
}
|
|
|
|
get isSaving() {
|
|
return this.stateStore.get(['isSaving']);
|
|
}
|
|
|
|
set isSaving(val) {
|
|
this.setState('isSaving', val);
|
|
}
|
|
|
|
get loadingMessage() {
|
|
return this.stateStore.get(['message']);
|
|
}
|
|
|
|
setLoadingState(loadingState) {
|
|
this.loadingState = loadingState;
|
|
}
|
|
|
|
setMessage(msg) {
|
|
this.setState('message', msg);
|
|
}
|
|
|
|
// Initialise the data, and fetch the data from the backend (if required).
|
|
// 'force' flag can be used for reloading the data from the backend.
|
|
initialise(dataDispatch, force) {
|
|
let state = this;
|
|
|
|
// Don't attempt to initialize again (if it's already in progress).
|
|
if (
|
|
state.loadingState !== LOADING_STATE.INIT ||
|
|
(force && state.loadingState === LOADING_STATE.LOADING)
|
|
) return;
|
|
|
|
state.setLoadingState(LOADING_STATE.LOADING);
|
|
state.setMessage(state.customLoadingText || gettext('Loading...'));
|
|
|
|
/*
|
|
* Fetch the data using getInitData(..) callback.
|
|
* `getInitData(..)` must be present in 'edit' mode.
|
|
*/
|
|
if(state.mode === 'edit' && !state.getInitData) {
|
|
throw new Error('getInitData must be passed for edit');
|
|
}
|
|
|
|
const initDataPromise = state.getInitData?.() ||
|
|
Promise.resolve({});
|
|
|
|
initDataPromise.then((data) => {
|
|
data = data || {};
|
|
|
|
if(state.mode === 'edit') {
|
|
// Set the origData to incoming data, useful for comparing.
|
|
state.initData = prepareData({...data, ...state.immutableData});
|
|
} else {
|
|
// In create mode, merge with defaults.
|
|
state.initData = prepareData({
|
|
...state.schema.defaults, ...data, ...state.immutableData
|
|
}, true);
|
|
}
|
|
|
|
state.schema.initialise(state.initData);
|
|
|
|
dataDispatch({
|
|
type: SCHEMA_STATE_ACTIONS.INIT,
|
|
payload: state.initData,
|
|
});
|
|
|
|
state.setLoadingState(LOADING_STATE.LOADED);
|
|
state.setMessage('');
|
|
state.setReady(true);
|
|
state.setState('isNew', state.schema.isNew(state.initData));
|
|
}).catch((err) => {
|
|
state.setMessage('');
|
|
state.setError({
|
|
name: 'apierror',
|
|
response: err,
|
|
message: _.escape(parseApiError(err)),
|
|
});
|
|
state.setLoadingState(LOADING_STATE.ERROR);
|
|
state.setReady(true);
|
|
});
|
|
}
|
|
|
|
validate(sessData) {
|
|
let state = this,
|
|
schema = state.schema;
|
|
|
|
// If schema does not have the data or does not have any 'onDataChange'
|
|
// callback, there is no need to validate the current data.
|
|
if(!state.isReady) return;
|
|
|
|
if(
|
|
!validateSchema(schema, sessData, (path, message) => {
|
|
message && state.setError({
|
|
name: state.accessPath(path), message: _.escape(message)
|
|
});
|
|
})
|
|
) state.setError({});
|
|
|
|
state.data = sessData;
|
|
state._changes = state.changes();
|
|
state.updateOptions();
|
|
state.onDataChange && state.onDataChange(state.isDirty, state._changes, state.errors);
|
|
}
|
|
|
|
changes(includeSkipChange=false) {
|
|
const state = this;
|
|
const sessData = state.data;
|
|
const schema = state.schema;
|
|
|
|
// Check if anything changed.
|
|
let dataDiff = getSchemaDataDiff(
|
|
schema, state.initData, sessData,
|
|
state.mode, state.keepCid, false, includeSkipChange
|
|
);
|
|
|
|
const isDirty = Object.keys(dataDiff).length > 0;
|
|
state.setState('isDirty', isDirty);
|
|
|
|
|
|
// Inform the callbacks about change in the data.
|
|
if(state.mode !== 'edit') {
|
|
// Merge the changed data with origData in 'create' mode.
|
|
dataDiff = _.assign({}, state.initData, dataDiff);
|
|
|
|
// Remove internal '__changeId' attribute.
|
|
delete dataDiff.__changeId;
|
|
|
|
// In case of 'non-edit' mode, changes are always there.
|
|
return dataDiff;
|
|
}
|
|
|
|
if (!isDirty) return {};
|
|
|
|
const idAttr = schema.idAttribute;
|
|
const idVal = state.initData[idAttr];
|
|
|
|
// Append 'idAttr' only if it actually exists
|
|
if (idVal) dataDiff[idAttr] = idVal;
|
|
|
|
return dataDiff;
|
|
}
|
|
|
|
get isNew() {
|
|
return this.stateStore.get(['isNew']);
|
|
}
|
|
|
|
set isNew(val) {
|
|
throw new Error('Property \'isNew\' is readonly.', val);
|
|
}
|
|
|
|
get isDirty() {
|
|
return this.stateStore.get(['isDirty']);
|
|
}
|
|
|
|
set isDirty(val) {
|
|
throw new Error('Property \'isDirty\' is readonly.', val);
|
|
}
|
|
|
|
get data() {
|
|
return this.dataStore.getState();
|
|
}
|
|
|
|
set data(_data) {
|
|
this.dataStore.setState(_data);
|
|
}
|
|
|
|
accessPath(path, key) {
|
|
return this.__pathGenerator.cached(
|
|
_.isUndefined(key) ? path : path.concat(key)
|
|
);
|
|
}
|
|
|
|
value(path) {
|
|
if (!path?.length) return this.data;
|
|
return _.get(this.data, path);
|
|
}
|
|
|
|
options(path) {
|
|
return this.optionStore.get(path.concat(FIELD_OPTIONS));
|
|
}
|
|
|
|
state(_state) {
|
|
return _state ?
|
|
this.stateStore.get([].concat(_state)) : this.stateStore.getState();
|
|
}
|
|
|
|
subscribe(path, listener, kind='options') {
|
|
switch(kind) {
|
|
case 'options':
|
|
return this.optionStore.subscribeForPath(
|
|
path.concat(FIELD_OPTIONS), listener
|
|
);
|
|
case 'states':
|
|
return this.stateStore.subscribeForPath(path, listener);
|
|
default:
|
|
return this.dataStore.subscribeForPath(path, listener);
|
|
}
|
|
}
|
|
|
|
subscribeOption(option, path, listener) {
|
|
return this.optionStore.subscribeForPath(
|
|
path.concat(FIELD_OPTIONS, option), listener
|
|
);
|
|
}
|
|
|
|
}
|