pgadmin4/web/pgadmin/static/js/SchemaView/SchemaState/SchemaState.js

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
);
}
}