2
0
mirror of https://github.com/frappe/books.git synced 2024-11-08 14:50:56 +00:00

incr: delete some stuff add typed doc.ts

This commit is contained in:
18alantom 2022-04-01 15:05:51 +05:30
parent 98e1a44686
commit cdb039d308
29 changed files with 960 additions and 1854 deletions

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
**/types.ts

View File

@ -1,8 +1,7 @@
import * as assert from 'assert';
import 'mocha';
import { getMapFromList } from 'schemas/helpers';
import { FieldTypeEnum, RawValue } from 'schemas/types';
import { getValueMapFromList, sleep } from 'utils';
import { getMapFromList, getValueMapFromList, sleep } from 'utils';
import { getDefaultMetaFieldValueMap, sqliteTypeMap } from '../../helpers';
import DatabaseCore from '../core';
import { FieldValueMap, SqliteTableInfo } from '../types';

View File

@ -1,831 +0,0 @@
import frappe from 'frappe';
import Observable from 'frappe/utils/observable';
import { knex } from 'knex';
import CacheManager from '../utils/cacheManager';
import { getRandomString } from '../utils/index';
export default class Database extends Observable {
constructor() {
super();
this.initTypeMap();
this.connectionParams = {};
this.cache = new CacheManager();
}
connect() {
this.knex = knex(this.connectionParams);
this.knex.on('query-error', (error) => {
error.type = this.getError(error);
});
this.executePostDbConnect();
}
close() {
//
}
async migrate() {
for (let doctype in frappe.models) {
// check if controller module
let meta = frappe.getMeta(doctype);
let baseDoctype = meta.getBaseDocType();
if (!meta.isSingle) {
if (await this.tableExists(baseDoctype)) {
await this.alterTable(baseDoctype);
} else {
await this.createTable(baseDoctype);
}
}
}
await this.commit();
await this.initializeSingles();
}
async initializeSingles() {
let singleDoctypes = frappe
.getModels((model) => model.isSingle)
.map((model) => model.name);
for (let doctype of singleDoctypes) {
if (await this.singleExists(doctype)) {
const singleValues = await this.getSingleFieldsToInsert(doctype);
singleValues.forEach(({ fieldname, value }) => {
let singleValue = frappe.getNewDoc({
doctype: 'SingleValue',
parent: doctype,
fieldname,
value,
});
singleValue.insert();
});
continue;
}
let meta = frappe.getMeta(doctype);
if (meta.fields.every((df) => df.default == null)) {
continue;
}
let defaultValues = meta.fields.reduce((doc, df) => {
if (df.default != null) {
doc[df.fieldname] = df.default;
}
return doc;
}, {});
await this.updateSingle(doctype, defaultValues);
}
}
async singleExists(doctype) {
let res = await this.knex('SingleValue')
.count('parent as count')
.where('parent', doctype)
.first();
return res.count > 0;
}
async getSingleFieldsToInsert(doctype) {
const existingFields = (
await frappe.db
.knex('SingleValue')
.where({ parent: doctype })
.select('fieldname')
).map(({ fieldname }) => fieldname);
return frappe
.getMeta(doctype)
.fields.map(({ fieldname, default: value }) => ({
fieldname,
value,
}))
.filter(
({ fieldname, value }) =>
!existingFields.includes(fieldname) && value !== undefined
);
}
tableExists(table) {
return this.knex.schema.hasTable(table);
}
async createTable(doctype, tableName = null) {
let fields = this.getValidFields(doctype);
return await this.runCreateTableQuery(tableName || doctype, fields);
}
runCreateTableQuery(doctype, fields) {
return this.knex.schema.createTable(doctype, (table) => {
for (let field of fields) {
this.buildColumnForTable(table, field);
}
});
}
async alterTable(doctype) {
// get columns
let diff = await this.getColumnDiff(doctype);
let newForeignKeys = await this.getNewForeignKeys(doctype);
return this.knex.schema
.table(doctype, (table) => {
if (diff.added.length) {
for (let field of diff.added) {
this.buildColumnForTable(table, field);
}
}
if (diff.removed.length) {
this.removeColumns(doctype, diff.removed);
}
})
.then(() => {
if (newForeignKeys.length) {
return this.addForeignKeys(doctype, newForeignKeys);
}
});
}
buildColumnForTable(table, field) {
let columnType = this.getColumnType(field);
if (!columnType) {
// In case columnType is "Table"
// childTable links are handled using the childTable's "parent" field
return;
}
let column = table[columnType](field.fieldname);
// primary key
if (field.fieldname === 'name') {
column.primary();
}
// default value
if (!!field.default && !(field.default instanceof Function)) {
column.defaultTo(field.default);
}
// required
if (
(!!field.required && !(field.required instanceof Function)) ||
field.fieldtype === 'Currency'
) {
column.notNullable();
}
// link
if (field.fieldtype === 'Link' && field.target) {
let meta = frappe.getMeta(field.target);
table
.foreign(field.fieldname)
.references('name')
.inTable(meta.getBaseDocType())
.onUpdate('CASCADE')
.onDelete('RESTRICT');
}
}
async getColumnDiff(doctype) {
const tableColumns = await this.getTableColumns(doctype);
const validFields = this.getValidFields(doctype);
const diff = { added: [], removed: [] };
for (let field of validFields) {
if (
!tableColumns.includes(field.fieldname) &&
this.getColumnType(field)
) {
diff.added.push(field);
}
}
const validFieldNames = validFields.map((field) => field.fieldname);
for (let column of tableColumns) {
if (!validFieldNames.includes(column)) {
diff.removed.push(column);
}
}
return diff;
}
async removeColumns(doctype, removed) {
for (let column of removed) {
await this.runRemoveColumnQuery(doctype, column);
}
}
async getNewForeignKeys(doctype) {
let foreignKeys = await this.getForeignKeys(doctype);
let newForeignKeys = [];
let meta = frappe.getMeta(doctype);
for (let field of meta.getValidFields({ withChildren: false })) {
if (
field.fieldtype === 'Link' &&
!foreignKeys.includes(field.fieldname)
) {
newForeignKeys.push(field);
}
}
return newForeignKeys;
}
async addForeignKeys(doctype, newForeignKeys) {
for (let field of newForeignKeys) {
this.addForeignKey(doctype, field);
}
}
async getForeignKeys(doctype, field) {
return [];
}
async getTableColumns(doctype) {
return [];
}
async get(doctype, name = null, fields = '*') {
let meta = frappe.getMeta(doctype);
let doc;
if (meta.isSingle) {
doc = await this.getSingle(doctype);
doc.name = doctype;
} else {
if (!name) {
throw new frappe.errors.ValueError('name is mandatory');
}
doc = await this.getOne(doctype, name, fields);
}
if (!doc) {
return;
}
await this.loadChildren(doc, meta);
return doc;
}
async loadChildren(doc, meta) {
// load children
let tableFields = meta.getTableFields();
for (let field of tableFields) {
doc[field.fieldname] = await this.getAll({
doctype: field.childtype,
fields: ['*'],
filters: { parent: doc.name },
orderBy: 'idx',
order: 'asc',
});
}
}
async getSingle(doctype) {
let values = await this.getAll({
doctype: 'SingleValue',
fields: ['fieldname', 'value'],
filters: { parent: doctype },
orderBy: 'fieldname',
order: 'asc',
});
let doc = {};
for (let row of values) {
doc[row.fieldname] = row.value;
}
return doc;
}
/**
* Get list of values from the singles table.
* @param {...string | Object} fieldnames list of fieldnames to get the values of
* @returns {Array<Object>} array of {parent, value, fieldname}.
* @example
* Database.getSingleValues('internalPrecision');
* // returns [{ fieldname: 'internalPrecision', parent: 'SystemSettings', value: '12' }]
* @example
* Database.getSingleValues({fieldname:'internalPrecision', parent: 'SystemSettings'});
* // returns [{ fieldname: 'internalPrecision', parent: 'SystemSettings', value: '12' }]
*/
async getSingleValues(...fieldnames) {
fieldnames = fieldnames.map((fieldname) => {
if (typeof fieldname === 'string') {
return { fieldname };
}
return fieldname;
});
let builder = frappe.db.knex('SingleValue');
builder = builder.where(fieldnames[0]);
fieldnames.slice(1).forEach(({ fieldname, parent }) => {
if (typeof parent === 'undefined') {
builder = builder.orWhere({ fieldname });
} else {
builder = builder.orWhere({ fieldname, parent });
}
});
let values = [];
try {
values = await builder.select('fieldname', 'value', 'parent');
} catch (error) {
if (error.message.includes('no such table')) {
return [];
}
throw error;
}
return values.map((value) => {
const fields = frappe.getMeta(value.parent).fields;
return this.getDocFormattedDoc(fields, values);
});
}
async getOne(doctype, name, fields = '*') {
let meta = frappe.getMeta(doctype);
let baseDoctype = meta.getBaseDocType();
const doc = await this.knex
.select(fields)
.from(baseDoctype)
.where('name', name)
.first();
if (!doc) {
return doc;
}
return this.getDocFormattedDoc(meta.fields, doc);
}
getDocFormattedDoc(fields, doc) {
// format for usage, not going into the db
const docFields = Object.keys(doc);
const filteredFields = fields.filter(({ fieldname }) =>
docFields.includes(fieldname)
);
const formattedValues = filteredFields.reduce((d, field) => {
const { fieldname } = field;
d[fieldname] = this.getDocFormattedValues(field, doc[fieldname]);
return d;
}, {});
return Object.assign(doc, formattedValues);
}
getDocFormattedValues(field, value) {
// format for usage, not going into the db
try {
if (field.fieldtype === 'Currency') {
return frappe.pesa(value);
}
} catch (err) {
err.message += ` value: '${value}' of type: ${typeof value}, fieldname: '${
field.fieldname
}', label: '${field.label}'`;
throw err;
}
return value;
}
triggerChange(doctype, name) {
this.trigger(`change:${doctype}`, { name }, 500);
this.trigger(`change`, { doctype, name }, 500);
// also trigger change for basedOn doctype
let meta = frappe.getMeta(doctype);
if (meta.basedOn) {
this.triggerChange(meta.basedOn, name);
}
}
async insert(doctype, doc) {
let meta = frappe.getMeta(doctype);
let baseDoctype = meta.getBaseDocType();
doc = this.applyBaseDocTypeFilters(doctype, doc);
// insert parent
if (meta.isSingle) {
await this.updateSingle(doctype, doc);
} else {
await this.insertOne(baseDoctype, doc);
}
// insert children
await this.insertChildren(meta, doc, baseDoctype);
this.triggerChange(doctype, doc.name);
return doc;
}
async insertChildren(meta, doc, doctype) {
let tableFields = meta.getTableFields();
for (let field of tableFields) {
let idx = 0;
for (let child of doc[field.fieldname] || []) {
this.prepareChild(doctype, doc.name, child, field, idx);
await this.insertOne(field.childtype, child);
idx++;
}
}
}
insertOne(doctype, doc) {
let fields = this.getValidFields(doctype);
if (!doc.name) {
doc.name = getRandomString();
}
let formattedDoc = this.getFormattedDoc(fields, doc);
return this.knex(doctype).insert(formattedDoc);
}
async update(doctype, doc) {
let meta = frappe.getMeta(doctype);
let baseDoctype = meta.getBaseDocType();
doc = this.applyBaseDocTypeFilters(doctype, doc);
// update parent
if (meta.isSingle) {
await this.updateSingle(doctype, doc);
} else {
await this.updateOne(baseDoctype, doc);
}
// insert or update children
await this.updateChildren(meta, doc, baseDoctype);
this.triggerChange(doctype, doc.name);
return doc;
}
async updateChildren(meta, doc, doctype) {
let tableFields = meta.getTableFields();
for (let field of tableFields) {
let added = [];
for (let child of doc[field.fieldname] || []) {
this.prepareChild(doctype, doc.name, child, field, added.length);
if (await this.exists(field.childtype, child.name)) {
await this.updateOne(field.childtype, child);
} else {
await this.insertOne(field.childtype, child);
}
added.push(child.name);
}
await this.runDeleteOtherChildren(field, doc.name, added);
}
}
updateOne(doctype, doc) {
let validFields = this.getValidFields(doctype);
let fieldsToUpdate = Object.keys(doc).filter((f) => f !== 'name');
let fields = validFields.filter((df) =>
fieldsToUpdate.includes(df.fieldname)
);
let formattedDoc = this.getFormattedDoc(fields, doc);
return this.knex(doctype)
.where('name', doc.name)
.update(formattedDoc)
.then(() => {
let cacheKey = `${doctype}:${doc.name}`;
if (this.cache.hexists(cacheKey)) {
for (let fieldname in formattedDoc) {
let value = formattedDoc[fieldname];
this.cache.hset(cacheKey, fieldname, value);
}
}
});
}
runDeleteOtherChildren(field, parent, added) {
// delete other children
return this.knex(field.childtype)
.where('parent', parent)
.andWhere('name', 'not in', added)
.delete();
}
async updateSingle(doctype, doc) {
let meta = frappe.getMeta(doctype);
await this.deleteSingleValues(doctype);
for (let field of meta.getValidFields({ withChildren: false })) {
let value = doc[field.fieldname];
if (value != null) {
let singleValue = frappe.getNewDoc({
doctype: 'SingleValue',
parent: doctype,
fieldname: field.fieldname,
value: value,
});
await singleValue.insert();
}
}
}
deleteSingleValues(name) {
return this.knex('SingleValue').where('parent', name).delete();
}
async rename(doctype, oldName, newName) {
let meta = frappe.getMeta(doctype);
let baseDoctype = meta.getBaseDocType();
await this.knex(baseDoctype)
.update({ name: newName })
.where('name', oldName)
.then(() => {
this.clearValueCache(doctype, oldName);
});
await frappe.db.commit();
this.triggerChange(doctype, newName);
}
prepareChild(parenttype, parent, child, field, idx) {
if (!child.name) {
child.name = getRandomString();
}
child.parent = parent;
child.parenttype = parenttype;
child.parentfield = field.fieldname;
child.idx = idx;
}
getValidFields(doctype) {
return frappe.getMeta(doctype).getValidFields({ withChildren: false });
}
getFormattedDoc(fields, doc) {
// format for storage, going into the db
let formattedDoc = {};
fields.map((field) => {
let value = doc[field.fieldname];
formattedDoc[field.fieldname] = this.getFormattedValue(field, value);
});
return formattedDoc;
}
getFormattedValue(field, value) {
// format for storage, going into the db
const type = typeof value;
if (field.fieldtype === 'Currency') {
let currency = value;
if (type === 'number' || type === 'string') {
currency = frappe.pesa(value);
}
const currencyValue = currency.store;
if (typeof currencyValue !== 'string') {
throw new Error(
`invalid currencyValue '${currencyValue}' of type '${typeof currencyValue}' on converting from '${value}' of type '${type}'`
);
}
return currencyValue;
}
if (value instanceof Date) {
if (field.fieldtype === 'Date') {
// date
return value.toISOString().substr(0, 10);
} else {
// datetime
return value.toISOString();
}
} else if (field.fieldtype === 'Link' && !value) {
// empty value must be null to satisfy
// foreign key constraint
return null;
} else {
return value;
}
}
applyBaseDocTypeFilters(doctype, doc) {
let meta = frappe.getMeta(doctype);
if (meta.filters) {
for (let fieldname in meta.filters) {
let value = meta.filters[fieldname];
if (typeof value !== 'object') {
doc[fieldname] = value;
}
}
}
return doc;
}
async deleteMany(doctype, names) {
for (const name of names) {
await this.delete(doctype, name);
}
}
async delete(doctype, name) {
let meta = frappe.getMeta(doctype);
let baseDoctype = meta.getBaseDocType();
await this.deleteOne(baseDoctype, name);
// delete children
let tableFields = frappe.getMeta(doctype).getTableFields();
for (let field of tableFields) {
await this.deleteChildren(field.childtype, name);
}
this.triggerChange(doctype, name);
}
async deleteOne(doctype, name) {
return this.knex(doctype)
.where('name', name)
.delete()
.then(() => {
this.clearValueCache(doctype, name);
});
}
deleteChildren(parenttype, parent) {
return this.knex(parenttype).where('parent', parent).delete();
}
async exists(doctype, name) {
return (await this.getValue(doctype, name)) ? true : false;
}
async getValue(doctype, filters, fieldname = 'name') {
let meta = frappe.getMeta(doctype);
let baseDoctype = meta.getBaseDocType();
if (typeof filters === 'string') {
filters = { name: filters };
}
if (meta.filters) {
Object.assign(filters, meta.filters);
}
let row = await this.getAll({
doctype: baseDoctype,
fields: [fieldname],
filters: filters,
start: 0,
limit: 1,
orderBy: 'name',
order: 'asc',
});
return row.length ? row[0][fieldname] : null;
}
async setValue(doctype, name, fieldname, value) {
return await this.setValues(doctype, name, {
[fieldname]: value,
});
}
async setValues(doctype, name, fieldValuePair) {
let doc = Object.assign({}, fieldValuePair, { name });
return this.updateOne(doctype, doc);
}
async getCachedValue(doctype, name, fieldname) {
let value = this.cache.hget(`${doctype}:${name}`, fieldname);
if (value == null) {
value = await this.getValue(doctype, name, fieldname);
}
return value;
}
async getAll({
doctype,
fields,
filters,
start,
limit,
groupBy,
orderBy = 'creation',
order = 'desc',
} = {}) {
let meta = frappe.getMeta(doctype);
let baseDoctype = meta.getBaseDocType();
if (!fields) {
fields = meta.getKeywordFields();
fields.push('name');
}
if (typeof fields === 'string') {
fields = [fields];
}
if (meta.filters) {
filters = Object.assign({}, filters, meta.filters);
}
let builder = this.knex.select(fields).from(baseDoctype);
this.applyFiltersToBuilder(builder, filters);
if (orderBy) {
builder.orderBy(orderBy, order);
}
if (groupBy) {
builder.groupBy(groupBy);
}
if (start) {
builder.offset(start);
}
if (limit) {
builder.limit(limit);
}
const docs = await builder;
return docs.map((doc) => this.getDocFormattedDoc(meta.fields, doc));
}
applyFiltersToBuilder(builder, filters) {
// {"status": "Open"} => `status = "Open"`
// {"status": "Open", "name": ["like", "apple%"]}
// => `status="Open" and name like "apple%"
// {"date": [">=", "2017-09-09", "<=", "2017-11-01"]}
// => `date >= 2017-09-09 and date <= 2017-11-01`
let filtersArray = [];
for (let field in filters) {
let value = filters[field];
let operator = '=';
let comparisonValue = value;
if (Array.isArray(value)) {
operator = value[0];
comparisonValue = value[1];
operator = operator.toLowerCase();
if (operator === 'includes') {
operator = 'like';
}
if (operator === 'like' && !comparisonValue.includes('%')) {
comparisonValue = `%${comparisonValue}%`;
}
}
filtersArray.push([field, operator, comparisonValue]);
if (Array.isArray(value) && value.length > 2) {
// multiple conditions
let operator = value[2];
let comparisonValue = value[3];
filtersArray.push([field, operator, comparisonValue]);
}
}
filtersArray.map((filter) => {
const [field, operator, comparisonValue] = filter;
if (operator === '=') {
builder.where(field, comparisonValue);
} else {
builder.where(field, operator, comparisonValue);
}
});
}
run(query, params) {
// run query
return this.sql(query, params);
}
sql(query, params) {
// run sql
return this.knex.raw(query, params);
}
async commit() {
try {
await this.sql('commit');
} catch (e) {
if (e.type !== frappe.errors.CannotCommitError) {
throw e;
}
}
}
clearValueCache(doctype, name) {
let cacheKey = `${doctype}:${name}`;
this.cache.hclear(cacheKey);
}
getColumnType(field) {
return this.typeMap[field.fieldtype];
}
getError(err) {
return frappe.errors.DatabaseError;
}
initTypeMap() {
this.typeMap = {};
}
executePostDbConnect() {
frappe.initializeMoneyMaker();
}
}

View File

@ -1,24 +0,0 @@
export const sqliteTypeMap = {
AutoComplete: 'text',
Currency: 'text',
Int: 'integer',
Float: 'float',
Percent: 'float',
Check: 'integer',
Code: 'text',
Date: 'text',
Datetime: 'text',
Time: 'text',
Text: 'text',
Data: 'text',
Link: 'text',
DynamicLink: 'text',
Password: 'text',
Select: 'text',
File: 'text',
Attach: 'text',
AttachImage: 'text',
Color: 'text',
};
export const validTypes = Object.keys(sqliteTypeMap);

View File

@ -1,115 +0,0 @@
import frappe from 'frappe';
import Database from './database';
import { sqliteTypeMap } from './helpers';
export default class SqliteDatabase extends Database {
constructor({ dbPath }) {
super();
this.dbPath = dbPath;
this.connectionParams = {
client: 'sqlite3',
connection: {
filename: this.dbPath,
},
pool: {
afterCreate(conn, done) {
conn.run('PRAGMA foreign_keys=ON');
done();
},
},
useNullAsDefault: true,
asyncStackTraces: process.env.NODE_ENV === 'development',
};
}
async addForeignKeys(doctype, newForeignKeys) {
await this.sql('PRAGMA foreign_keys=OFF');
await this.sql('BEGIN TRANSACTION');
const tempName = 'TEMP' + doctype;
// create temp table
await this.createTable(doctype, tempName);
try {
// copy from old to new table
await this.knex(tempName).insert(this.knex.select().from(doctype));
} catch (err) {
await this.sql('ROLLBACK');
await this.sql('PRAGMA foreign_keys=ON');
const rows = await this.knex.select().from(doctype);
await this.prestigeTheTable(doctype, rows);
return;
}
// drop old table
await this.knex.schema.dropTable(doctype);
// rename new table
await this.knex.schema.renameTable(tempName, doctype);
await this.sql('COMMIT');
await this.sql('PRAGMA foreign_keys=ON');
}
removeColumns() {
// pass
}
async getTableColumns(doctype) {
return (await this.sql(`PRAGMA table_info(${doctype})`)).map((d) => d.name);
}
async getForeignKeys(doctype) {
return (await this.sql(`PRAGMA foreign_key_list(${doctype})`)).map(
(d) => d.from
);
}
initTypeMap() {
this.typeMap = sqliteTypeMap;
}
getError(err) {
let errorType = frappe.errors.DatabaseError;
if (err.message.includes('FOREIGN KEY')) {
errorType = frappe.errors.LinkValidationError;
}
if (err.message.includes('SQLITE_ERROR: cannot commit')) {
errorType = frappe.errors.CannotCommitError;
}
if (err.message.includes('SQLITE_CONSTRAINT: UNIQUE constraint failed:')) {
errorType = frappe.errors.DuplicateEntryError;
}
return errorType;
}
async prestigeTheTable(tableName, tableRows) {
const max = 200;
// Alter table hacx for sqlite in case of schema change.
const tempName = `__${tableName}`;
await this.knex.schema.dropTableIfExists(tempName);
await this.knex.raw('PRAGMA foreign_keys=OFF');
await this.createTable(tableName, tempName);
if (tableRows.length > 200) {
const fi = Math.floor(tableRows.length / max);
for (let i = 0; i <= fi; i++) {
const rowSlice = tableRows.slice(i * max, i + 1 * max);
if (rowSlice.length === 0) {
break;
}
await this.knex.batchInsert(tempName, rowSlice);
}
} else {
await this.knex.batchInsert(tempName, tableRows);
}
await this.knex.schema.dropTable(tableName);
await this.knex.schema.renameTable(tempName, tableName);
await this.knex.raw('PRAGMA foreign_keys=ON');
}
}

View File

@ -1,4 +1,4 @@
import { Frappe } from 'frappe/core/frappe';
import { Frappe } from 'frappe';
interface AuthConfig {
serverURL: string;

View File

@ -1,5 +1,5 @@
import { DatabaseDemux } from '@/demux/db';
import { Frappe } from 'frappe/core/frappe';
import { Frappe } from 'frappe';
import Money from 'pesa/dist/types/src/money';
import { FieldType, FieldTypeEnum, RawValue, SchemaMap } from 'schemas/types';
import { DatabaseBase, GetAllOptions } from 'utils/db/types';

View File

@ -1,9 +1,10 @@
import { Field, Model } from '@/types/model';
import Doc from 'frappe/model/document';
import Doc from 'frappe/model/doc';
import Meta from 'frappe/model/meta';
import { getDuplicates, getRandomString } from 'frappe/utils';
import Observable from 'frappe/utils/observable';
import { Frappe } from './frappe';
import { Frappe } from '..';
import { DocValue } from './types';
type DocMap = Record<string, Doc | undefined>;
type MetaMap = Record<string, Meta | undefined>;
@ -109,8 +110,8 @@ export class DocHandler {
}
}
getDocFromCache(doctype: string, name: string): Doc | undefined {
const doc = (this.docs?.[doctype] as DocMap)?.[name];
getDocFromCache(schemaName: string, name: string): Doc | undefined {
const doc = (this.docs?.[schemaName] as DocMap)?.[name];
return doc;
}
@ -251,4 +252,10 @@ export class DocHandler {
await doc.insert();
}
}
getCachedValue(
schemaName: string,
name: string,
fieldname: string
): DocValue {}
}

View File

@ -1,9 +1,9 @@
import Doc from 'frappe/model/doc';
import Money from 'pesa/dist/types/src/money';
import { RawValue } from 'schemas/types';
export type DocValue = string | number | boolean | Date | Money;
export type DocValueMap = Record<string, DocValue | DocValueMap[]>;
export type DocValue = string | number | boolean | Date | Money | null;
export type DocValueMap = Record<string, DocValue | Doc[] | DocValueMap[]>;
export type RawValueMap = Record<string, RawValue | RawValueMap[]>;
export type SingleValue<T> = {

View File

@ -1,321 +0,0 @@
import { getMoneyMaker } from 'pesa';
import { markRaw } from 'vue';
import { AuthHandler } from './core/authHandler';
import { asyncHandler, getDuplicates, getRandomString } from './utils';
import {
DEFAULT_DISPLAY_PRECISION,
DEFAULT_INTERNAL_PRECISION,
} from './utils/consts';
import * as errors from './utils/errors';
import { format } from './utils/format';
import Observable from './utils/observable';
import { t, T } from './utils/translation';
export class Frappe {
t = t;
T = T;
format = format;
errors = errors;
isElectron = false;
isServer = false;
constructor() {
this.auth = new AuthHandler();
}
async initializeAndRegister(customModels = {}, force = false) {
this.init(force);
this.Meta = (await import('frappe/model/meta')).default;
this.Document = (await import('frappe/model/document')).default;
const coreModels = await import('frappe/models');
this.registerModels(coreModels.default);
this.registerModels(customModels);
}
init(force) {
if (this._initialized && !force) return;
// Initialize Globals
this.metaCache = {};
this.models = {};
this.methods = {};
this.errorLog = [];
// temp params while calling routes
this.temp = {};
this.docs = new Observable();
this.events = new Observable();
this._initialized = true;
}
registerModels(models) {
// register models from app/models/index.js
for (let doctype in models) {
let metaDefinition = models[doctype];
if (!metaDefinition.name) {
throw new Error(`Name is mandatory for ${doctype}`);
}
if (metaDefinition.name !== doctype) {
throw new Error(
`Model name mismatch for ${doctype}: ${metaDefinition.name}`
);
}
let fieldnames = (metaDefinition.fields || [])
.map((df) => df.fieldname)
.sort();
let duplicateFieldnames = getDuplicates(fieldnames);
if (duplicateFieldnames.length > 0) {
throw new Error(
`Duplicate fields in ${doctype}: ${duplicateFieldnames.join(', ')}`
);
}
this.models[doctype] = metaDefinition;
}
}
registerMethod({ method, handler }) {
this.methods[method] = handler;
if (this.app) {
// add to router if client-server
this.app.post(
`/api/method/${method}`,
asyncHandler(async function (request, response) {
let data = await handler(request.body);
if (data === undefined) {
data = {};
}
return response.json(data);
})
);
}
}
async initializeMoneyMaker(currency) {
currency ??= 'XXX';
// to be called after db initialization
const values =
(await frappe.db?.getSingleValues(
{
fieldname: 'internalPrecision',
parent: 'SystemSettings',
},
{
fieldname: 'displayPrecision',
parent: 'SystemSettings',
}
)) ?? [];
let { internalPrecision: precision, displayPrecision: display } =
values.reduce((acc, { fieldname, value }) => {
acc[fieldname] = value;
return acc;
}, {});
if (typeof precision === 'undefined') {
precision = DEFAULT_INTERNAL_PRECISION;
}
if (typeof precision === 'string') {
precision = parseInt(precision);
}
if (typeof display === 'undefined') {
display = DEFAULT_DISPLAY_PRECISION;
}
if (typeof display === 'string') {
display = parseInt(display);
}
this.pesa = getMoneyMaker({
currency,
precision,
display,
wrapper: markRaw,
});
}
getModels(filterFunction) {
let models = [];
for (let doctype in this.models) {
models.push(this.models[doctype]);
}
return filterFunction ? models.filter(filterFunction) : models;
}
async call({ method, args }) {
if (this.isServer) {
if (this.methods[method]) {
return await this.methods[method](args);
} else {
throw new Error(`${method} not found`);
}
}
let url = `/api/method/${method}`;
let response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(args || {}),
});
return await response.json();
}
addToCache(doc) {
if (!this.docs) return;
// add to `docs` cache
if (doc.doctype && doc.name) {
if (!this.docs[doc.doctype]) {
this.docs[doc.doctype] = {};
}
this.docs[doc.doctype][doc.name] = doc;
// singles available as first level objects too
if (doc.doctype === doc.name) {
this[doc.name] = doc;
}
// propogate change to `docs`
doc.on('change', (params) => {
this.docs.trigger('change', params);
});
}
}
removeFromCache(doctype, name) {
try {
delete this.docs[doctype][name];
} catch (e) {
console.warn(`Document ${doctype} ${name} does not exist`);
}
}
isDirty(doctype, name) {
return (
(this.docs &&
this.docs[doctype] &&
this.docs[doctype][name] &&
this.docs[doctype][name]._dirty) ||
false
);
}
getDocFromCache(doctype, name) {
if (this.docs && this.docs[doctype] && this.docs[doctype][name]) {
return this.docs[doctype][name];
}
}
getMeta(doctype) {
if (!this.metaCache[doctype]) {
let model = this.models[doctype];
if (!model) {
throw new Error(`${doctype} is not a registered doctype`);
}
let metaClass = model.metaClass || this.Meta;
this.metaCache[doctype] = new metaClass(model);
}
return this.metaCache[doctype];
}
async getDoc(doctype, name, options = { skipDocumentCache: false }) {
let doc = options.skipDocumentCache
? null
: this.getDocFromCache(doctype, name);
if (!doc) {
doc = new (this.getDocumentClass(doctype))({
doctype: doctype,
name: name,
});
await doc.load();
this.addToCache(doc);
}
return doc;
}
getDocumentClass(doctype) {
const meta = this.getMeta(doctype);
return meta.documentClass || this.Document;
}
async getSingle(doctype) {
return await this.getDoc(doctype, doctype);
}
async getDuplicate(doc) {
const newDoc = await this.getEmptyDoc(doc.doctype);
for (let field of this.getMeta(doc.doctype).getValidFields()) {
if (['name', 'submitted'].includes(field.fieldname)) continue;
if (field.fieldtype === 'Table') {
newDoc[field.fieldname] = (doc[field.fieldname] || []).map((d) => {
let newd = Object.assign({}, d);
newd.name = '';
return newd;
});
} else {
newDoc[field.fieldname] = doc[field.fieldname];
}
}
return newDoc;
}
getEmptyDoc(doctype, cacheDoc = true) {
let doc = this.getNewDoc({ doctype: doctype });
doc._notInserted = true;
doc.name = getRandomString();
if (cacheDoc) {
this.addToCache(doc);
}
return doc;
}
getNewDoc(data) {
let doc = new (this.getDocumentClass(data.doctype))(data);
doc.setDefaults();
return doc;
}
createMeta(fields) {
return new this.Meta({ isCustom: 1, fields });
}
async syncDoc(data) {
let doc;
if (await this.db.exists(data.doctype, data.name)) {
doc = await this.getDoc(data.doctype, data.name);
Object.assign(doc, data);
await doc.update();
} else {
doc = this.getNewDoc(data);
await doc.insert();
}
}
close() {
this.db.close();
this.auth.logout();
}
store = {
isDevelopment: false,
appVersion: '',
};
}
export { T, t };
export default new Frappe();

View File

@ -1,19 +1,17 @@
import { ErrorLog } from '@/errorHandling';
import { Model } from '@/types/model';
import Doc from 'frappe/model/document';
import Meta from 'frappe/model/meta';
import Doc from 'frappe/model/doc';
import { getMoneyMaker, MoneyMaker } from 'pesa';
import { markRaw } from 'vue';
import { AuthHandler } from './core/authHandler';
import { DatabaseHandler } from './core/dbHandler';
import { DocHandler } from './core/docHandler';
import {
DEFAULT_DISPLAY_PRECISION,
DEFAULT_INTERNAL_PRECISION,
} from '../utils/consts';
import * as errors from '../utils/errors';
import { format } from '../utils/format';
import { t, T } from '../utils/translation';
import { AuthHandler } from './authHandler';
import { DatabaseHandler } from './dbHandler';
import { DocHandler } from './docHandler';
} from './utils/consts';
import * as errors from './utils/errors';
import { format } from './utils/format';
import { t, T } from './utils/translation';
export class Frappe {
t = t;
@ -30,8 +28,7 @@ export class Frappe {
doc: DocHandler;
db: DatabaseHandler;
Meta?: typeof Meta;
Document?: typeof Doc;
Doc?: typeof Doc;
_initialized: boolean = false;
@ -66,8 +63,7 @@ export class Frappe {
async initializeAndRegister(customModels = {}, force = false) {
await this.init(force);
this.Meta = (await import('frappe/model/meta')).default;
this.Document = (await import('frappe/model/document')).default;
this.Doc = (await import('frappe/model/doc')).default;
const coreModels = await import('frappe/models');
this.doc.registerModels(coreModels.default as Record<string, Model>);

691
frappe/model/doc.ts Normal file
View File

@ -0,0 +1,691 @@
import telemetry from '@/telemetry/telemetry';
import { Verb } from '@/telemetry/types';
import { DocValue, DocValueMap } from 'frappe/core/types';
import {
Conflict,
MandatoryError,
NotFoundError,
ValidationError,
} from 'frappe/utils/errors';
import Observable from 'frappe/utils/observable';
import Money from 'pesa/dist/types/src/money';
import {
Field,
FieldTypeEnum,
OptionField,
Schema,
TargetField,
} from 'schemas/types';
import { getIsNullOrUndef, getMapFromList } from 'utils';
import frappe from '..';
import { getRandomString, isPesa } from '../utils/index';
import {
areDocValuesEqual,
getMissingMandatoryMessage,
getPreDefaultValues,
shouldApplyFormula,
} from './helpers';
import { setName } from './naming';
import {
DefaultMap,
DependsOnMap,
FormulaMap,
RequiredMap,
ValidationMap,
} from './types';
import { validateSelect } from './validationFunction';
export default class Doc extends Observable<DocValue | Doc[]> {
name?: string;
schema: Readonly<Schema>;
fieldMap: Record<string, Field>;
/**
* Fields below are used by child docs to maintain
* reference w.r.t their parent doc.
*/
idx?: number;
parentdoc?: Doc;
parentfield?: string;
_links?: Record<string, Doc>;
_dirty: boolean = true;
_notInserted: boolean = true;
flags = {
submitAction: false,
revertAction: false,
};
constructor(schema: Schema, data: DocValueMap) {
super();
this.schema = schema;
this._setInitialValues(data);
this.fieldMap = getMapFromList(schema.fields, 'fieldname');
}
get schemaName(): string {
return this.schema.name;
}
get isNew(): boolean {
return this._notInserted;
}
get tableFields(): TargetField[] {
return this.schema.fields.filter(
(f) => f.fieldtype === FieldTypeEnum.Table
) as TargetField[];
}
_setInitialValues(data: DocValueMap) {
for (const fieldname in data) {
const value = data[fieldname];
if (Array.isArray(value)) {
for (const row of value) {
this.push(fieldname, row);
}
} else {
this[fieldname] = value;
}
}
// set unset fields as null
for (const field of this.schema.fields) {
if (this[field.fieldname] === undefined) {
this[field.fieldname] = null;
}
}
}
setDirty(value: boolean) {
this._dirty = value;
if (this.schema.isChild && this.parentdoc) {
this.parentdoc._dirty = value;
}
}
// set value and trigger change
async set(fieldname: string | DocValueMap, value?: DocValue | Doc[]) {
if (typeof fieldname === 'object') {
this.setMultiple(fieldname as DocValueMap);
return;
}
if (fieldname === 'numberSeries' && !this._notInserted) {
return;
}
if (value === undefined) {
return;
}
if (
this.fieldMap[fieldname] === undefined ||
(this[fieldname] !== undefined &&
areDocValuesEqual(this[fieldname] as DocValue, value as DocValue))
) {
return;
}
this.setDirty(true);
if (Array.isArray(value)) {
this[fieldname] = value.map((row, i) => {
row.idx = i;
return row;
});
} else {
const field = this.fieldMap[fieldname];
await this.validateField(field, value);
this[fieldname] = value;
}
// always run applyChange from the parentdoc
if (this.schema.isChild && this.parentdoc) {
await this.applyChange(fieldname);
await this.parentdoc.applyChange(this.parentfield as string);
} else {
await this.applyChange(fieldname);
}
}
async setMultiple(docValueMap: DocValueMap) {
for (const fieldname in docValueMap) {
await this.set(fieldname, docValueMap[fieldname] as DocValue | Doc[]);
}
}
async applyChange(fieldname: string) {
await this.applyFormula(fieldname);
await this.trigger('change', {
doc: this,
changed: fieldname,
});
}
setDefaults() {
for (const field of this.schema.fields) {
if (!getIsNullOrUndef(this[field.fieldname])) {
continue;
}
let defaultValue: DocValue | Doc[] = getPreDefaultValues(field.fieldtype);
const defaultFunction = this.defaults[field.fieldname];
if (defaultFunction !== undefined) {
defaultValue = defaultFunction();
} else if (field.default !== undefined) {
defaultValue = field.default;
}
if (field.fieldtype === 'Currency' && !isPesa(defaultValue)) {
defaultValue = frappe.pesa!(defaultValue as string | number);
}
this[field.fieldname] = defaultValue;
}
}
append(fieldname: string, docValueMap: Doc | DocValueMap = {}) {
// push child row and trigger change
this.push(fieldname, docValueMap);
this._dirty = true;
this.applyChange(fieldname);
}
push(fieldname: string, docValueMap: Doc | DocValueMap = {}) {
// push child row without triggering change
this[fieldname] ??= [];
const childDoc = this._initChild(docValueMap, fieldname);
(this[fieldname] as Doc[]).push(childDoc);
}
_initChild(docValueMap: Doc | DocValueMap, fieldname: string): Doc {
if (docValueMap instanceof Doc) {
return docValueMap;
}
const data: Record<string, unknown> = Object.assign({}, docValueMap);
data.parent = this.name;
data.parenttype = this.schemaName;
data.parentfield = fieldname;
data.parentdoc = this;
if (!data.idx) {
data.idx = ((this[fieldname] as Doc[]) || []).length;
}
if (!data.name) {
data.name = getRandomString();
}
const childSchemaName = this.fieldMap[fieldname] as TargetField;
const schema = frappe.db.schemaMap[childSchemaName.target];
const childDoc = new Doc(schema, data as DocValueMap);
childDoc.setDefaults();
return childDoc;
}
async validateInsert() {
this.validateMandatory();
await this.validateFields();
}
validateMandatory() {
const checkForMandatory: Doc[] = [this];
const tableFields = this.schema.fields.filter(
(f) => f.fieldtype === FieldTypeEnum.Table
) as TargetField[];
for (const field of tableFields) {
const childDocs = this.get(field.fieldname) as Doc[];
checkForMandatory.push(...childDocs);
}
const missingMandatoryMessage = checkForMandatory
.map((doc) => getMissingMandatoryMessage(doc))
.filter(Boolean);
if (missingMandatoryMessage.length > 0) {
const fields = missingMandatoryMessage.join('\n');
const message = frappe.t`Value missing for ${fields}`;
throw new MandatoryError(message);
}
}
async validateFields() {
const fields = this.schema.fields;
for (const field of fields) {
if (field.fieldtype === FieldTypeEnum.Table) {
continue;
}
const value = this.get(field.fieldname) as DocValue;
await this.validateField(field, value);
}
}
async validateField(field: Field, value: DocValue) {
if (field.fieldtype == 'Select') {
validateSelect(field as OptionField, value as string);
}
if (value === null || value === undefined) {
return;
}
const validator = this.validations[field.fieldname];
if (validator === undefined) {
return;
}
await validator(value);
}
getValidDict(): DocValueMap {
const data: DocValueMap = {};
for (const field of this.schema.fields) {
let value = this[field.fieldname] as DocValue | DocValueMap[];
if (Array.isArray(value)) {
value = value.map((doc) => (doc as Doc).getValidDict());
}
if (isPesa(value)) {
value = (value as Money).copy();
}
data[field.fieldname] = value;
}
return data;
}
setBaseMetaValues() {
if (this.schema.isSubmittable && typeof this.submitted !== 'boolean') {
this.submitted = false;
this.cancelled = false;
}
if (!this.createdBy) {
this.createdBy = frappe.auth.session.user;
}
if (!this.created) {
this.created = new Date();
}
this.updateModified();
}
updateModified() {
this.modifiedBy = frappe.auth.session.user;
this.modified = new Date();
}
async load() {
if (this.name === undefined) {
return;
}
const data = await frappe.db.get(this.schemaName, this.name);
if (data && data.name) {
this.syncValues(data);
if (this.schema.isSingle) {
this.setDefaults();
}
await this.loadLinks();
} else {
throw new NotFoundError(`Not Found: ${this.schemaName} ${this.name}`);
}
}
async loadLinks() {
this._links = {};
const inlineLinks = this.schema.fields.filter((f) => f.inline);
for (const f of inlineLinks) {
await this.loadLink(f.fieldname);
}
}
async loadLink(fieldname: string) {
this._links ??= {};
const field = this.fieldMap[fieldname] as TargetField;
if (field === undefined) {
return;
}
const value = this.get(fieldname);
if (getIsNullOrUndef(value) || field.target === undefined) {
return;
}
this._links[fieldname] = await frappe.doc.getDoc(
field.target,
value as string
);
}
getLink(fieldname: string) {
return this._links ? this._links[fieldname] : null;
}
syncValues(data: DocValueMap) {
this.clearValues();
this._setInitialValues(data);
this._dirty = false;
this.trigger('change', {
doc: this,
});
}
clearValues() {
for (const { fieldname } of this.schema.fields) {
this[fieldname] = null;
}
this._dirty = true;
this._notInserted = true;
}
setChildIdx() {
const childFields = this.schema.fields.filter(
(f) => f.fieldtype === FieldTypeEnum.Table
) as TargetField[];
for (const field of childFields) {
const childDocs = (this.get(field.fieldname) as Doc[]) ?? [];
for (let i = 0; i < childDocs.length; i++) {
childDocs[i].idx = i;
}
}
}
async compareWithCurrentDoc() {
if (this.isNew || !this.name) {
return;
}
const currentDoc = await frappe.db.get(this.schemaName, this.name);
// check for conflict
if (
currentDoc &&
(this.modified as Date) !== (currentDoc.modified as Date)
) {
throw new Conflict(
frappe.t`Document ${this.doctype} ${this.name} has been modified after loading`
);
}
if (this.submitted && !this.schema.isSubmittable) {
throw new ValidationError(
frappe.t`Document type ${this.doctype} is not submittable`
);
}
// set submit action flag
if (this.submitted && !currentDoc.submitted) {
this.flags.submitAction = true;
}
if (currentDoc.submitted && !this.submitted) {
this.flags.revertAction = true;
}
}
async applyFormula(fieldname?: string) {
if (fieldname && this.formulas[fieldname] === undefined) {
return false;
}
const doc = this;
let changed = false;
const childDocs = this.tableFields
.map((f) => (this.get(f.fieldname) as Doc[]) ?? [])
.flat();
// children
for (const row of childDocs) {
const formulaFields = Object.keys(this.formulas).map(
(fn) => this.fieldMap[fn]
);
changed ||= await this.applyFormulaForFields(
formulaFields,
row,
fieldname
);
}
// parent or child row
const formulaFields = Object.keys(this.formulas).map(
(fn) => this.fieldMap[fn]
);
changed ||= await this.applyFormulaForFields(formulaFields, doc, fieldname);
return changed;
}
async applyFormulaForFields(
formulaFields: Field[],
doc: Doc,
fieldname?: string
) {
let changed = false;
for (const field of formulaFields) {
if (!shouldApplyFormula(field, doc, fieldname)) {
continue;
}
const newVal = await this.getValueFromFormula(field, doc);
const previousVal = doc.get(field.fieldname);
const isSame = areDocValuesEqual(newVal as DocValue, previousVal);
if (newVal === undefined || isSame) {
continue;
}
doc[field.fieldname] = newVal;
changed = true;
}
return changed;
}
async getValueFromFormula(field: Field, doc: Doc) {
let value: Doc[] | DocValue;
const formula = doc.formulas[field.fieldtype];
if (formula === undefined) {
return;
}
value = await formula();
if (Array.isArray(value) && field.fieldtype === FieldTypeEnum.Table) {
value = value.map((row) => this._initChild(row, field.fieldname));
}
return value;
}
async commit() {
// re-run triggers
this.setChildIdx();
await this.applyFormula();
await this.trigger('validate', null);
}
async insert() {
await setName(this);
this.setBaseMetaValues();
await this.commit();
await this.validateInsert();
await this.trigger('beforeInsert', null);
const oldName = this.name!;
const data = await frappe.db.insert(this.schemaName, this.getValidDict());
this.syncValues(data);
if (oldName !== this.name) {
frappe.doc.removeFromCache(this.schemaName, oldName);
}
await this.trigger('afterInsert', null);
await this.trigger('afterSave', null);
telemetry.log(Verb.Created, this.schemaName);
return this;
}
async update() {
await this.compareWithCurrentDoc();
await this.commit();
await this.trigger('beforeUpdate');
// before submit
if (this.flags.submitAction) await this.trigger('beforeSubmit');
if (this.flags.revertAction) await this.trigger('beforeRevert');
// update modifiedBy and modified
this.updateModified();
const data = this.getValidDict();
await frappe.db.update(this.schemaName, data);
this.syncValues(data);
await this.trigger('afterUpdate');
await this.trigger('afterSave');
// after submit
if (this.flags.submitAction) await this.trigger('afterSubmit');
if (this.flags.revertAction) await this.trigger('afterRevert');
return this;
}
async insertOrUpdate() {
if (this._notInserted) {
return await this.insert();
} else {
return await this.update();
}
}
async delete() {
await this.trigger('beforeDelete');
await frappe.db.delete(this.schemaName, this.name!);
await this.trigger('afterDelete');
telemetry.log(Verb.Deleted, this.schemaName);
}
async submitOrRevert(isSubmit: boolean) {
const wasSubmitted = this.submitted;
this.submitted = isSubmit;
try {
await this.update();
} catch (e) {
this.submitted = wasSubmitted;
throw e;
}
}
async submit() {
this.cancelled = false;
await this.submitOrRevert(true);
}
async revert() {
await this.submitOrRevert(false);
}
async rename(newName: string) {
await this.trigger('beforeRename');
await frappe.db.rename(this.schemaName, this.name!, newName);
this.name = newName;
await this.trigger('afterRename');
}
async trigger(event: string, params?: unknown) {
if (this[event]) {
await (this[event] as Function)(params);
}
await super.trigger(event, params);
}
getSum(tablefield: string, childfield: string, convertToFloat = true) {
const childDocs = (this.get(tablefield) as Doc[]) ?? [];
const sum = childDocs
.map((d) => {
const value = d.get(childfield) ?? 0;
if (!isPesa(value)) {
try {
return frappe.pesa(value as string | number);
} catch (err) {
(
err as Error
).message += ` value: '${value}' of type: ${typeof value}, fieldname: '${tablefield}', childfield: '${childfield}'`;
throw err;
}
}
return value as Money;
})
.reduce((a, b) => a.add(b), frappe.pesa(0));
if (convertToFloat) {
return sum.float;
}
return sum;
}
getFrom(schemaName: string, name: string, fieldname: string) {
if (name === undefined || fieldname === undefined) {
return '';
}
return frappe.doc.getCachedValue(schemaName, name, fieldname);
}
async duplicate() {
const updateMap: DocValueMap = {};
const docValueMap = this.getValidDict();
const fieldnames = this.schema.fields.map((f) => f.fieldname);
for (const fn of fieldnames) {
const value = docValueMap[fn];
if (getIsNullOrUndef(value)) {
continue;
}
if (Array.isArray(value)) {
value.forEach((row) => {
delete row.name;
delete row.parent;
});
}
updateMap[fn] = value;
}
if (this.numberSeries) {
delete updateMap.name;
} else {
updateMap.name = updateMap.name + ' CPY';
}
const doc = frappe.doc.getEmptyDoc(this.schemaName, false);
await doc.setMultiple(updateMap);
await doc.insert();
}
formulas: FormulaMap = {};
defaults: DefaultMap = {};
validations: ValidationMap = {};
required: RequiredMap = {};
dependsOn: DependsOnMap = {};
}

103
frappe/model/helpers.ts Normal file
View File

@ -0,0 +1,103 @@
import frappe from 'frappe';
import { DocValue } from 'frappe/core/types';
import { isPesa } from 'frappe/utils';
import { isEqual } from 'lodash';
import Money from 'pesa/dist/types/src/money';
import { Field, FieldType, FieldTypeEnum } from 'schemas/types';
import { getIsNullOrUndef } from 'utils';
import Doc from './doc';
export function areDocValuesEqual(
dvOne: DocValue | Doc[],
dvTwo: DocValue | Doc[]
): boolean {
if (['string', 'number'].includes(typeof dvOne) || dvOne instanceof Date) {
return dvOne === dvTwo;
}
if (isPesa(dvOne)) {
try {
return (dvOne as Money).eq(dvTwo as string | number);
} catch {
return false;
}
}
return isEqual(dvOne, dvTwo);
}
export function getPreDefaultValues(fieldtype: FieldType): DocValue | Doc[] {
switch (fieldtype) {
case FieldTypeEnum.Table:
return [] as Doc[];
case FieldTypeEnum.Currency:
return frappe.pesa!(0.0);
case FieldTypeEnum.Int:
case FieldTypeEnum.Float:
return 0;
default:
return null;
}
}
export function getMissingMandatoryMessage(doc: Doc) {
const mandatoryFields = getMandatory(doc);
const message = mandatoryFields
.filter((f) => {
const value = doc.get(f.fieldname);
const isNullOrUndef = getIsNullOrUndef(value);
if (f.fieldtype === FieldTypeEnum.Table) {
return isNullOrUndef || (value as Doc[])?.length === 0;
}
return isNullOrUndef || value === '';
})
.map((f) => f.label)
.join(', ');
if (message && doc.schema.isChild && doc.parentdoc && doc.parentfield) {
const parentfield = doc.parentdoc.fieldMap[doc.parentfield];
return `${parentfield.label} Row ${(doc.idx ?? 0) + 1}: ${message}`;
}
return message;
}
function getMandatory(doc: Doc): Field[] {
const mandatoryFields: Field[] = [];
for (const field of doc.schema.fields) {
if (field.required) {
mandatoryFields.push(field);
}
const requiredFunction = doc.required[field.fieldname];
if (typeof requiredFunction !== 'function') {
continue;
}
if (requiredFunction()) {
mandatoryFields.push(field);
}
}
return mandatoryFields;
}
export function shouldApplyFormula(field: Field, doc: Doc, fieldname?: string) {
if (!doc.formulas[field.fieldtype]) {
return false;
}
if (field.readOnly) {
return true;
}
const dependsOn = doc.dependsOn[field.fieldname] ?? [];
if (fieldname && dependsOn.includes(fieldname)) {
return true;
}
const value = doc.get(field.fieldname);
return getIsNullOrUndef(value);
}

View File

@ -1,114 +0,0 @@
const cloneDeep = require('lodash/cloneDeep');
module.exports = {
extend: (base, target, options = {}) => {
base = cloneDeep(base);
const fieldsToMerge = (target.fields || []).map(df => df.fieldname);
const fieldsToRemove = options.skipFields || [];
const overrideProps = options.overrideProps || [];
for (let prop of overrideProps) {
if (base.hasOwnProperty(prop)) {
delete base[prop];
}
}
let mergeFields = (baseFields, targetFields) => {
let fields = cloneDeep(baseFields);
fields = fields
.filter(df => !fieldsToRemove.includes(df.fieldname))
.map(df => {
if (fieldsToMerge.includes(df.fieldname)) {
let copy = cloneDeep(df);
return Object.assign(
copy,
targetFields.find(tdf => tdf.fieldname === df.fieldname)
);
}
return df;
});
let fieldsAdded = fields.map(df => df.fieldname);
let fieldsToAdd = targetFields.filter(
df => !fieldsAdded.includes(df.fieldname)
);
return fields.concat(fieldsToAdd);
};
let fields = mergeFields(base.fields, target.fields || []);
let out = Object.assign(base, target);
out.fields = fields;
return out;
},
commonFields: [
{
fieldname: 'name',
fieldtype: 'Data',
required: 1
}
],
submittableFields: [
{
fieldname: 'submitted',
fieldtype: 'Check',
required: 1
}
],
parentFields: [
{
fieldname: 'owner',
fieldtype: 'Data',
required: 1
},
{
fieldname: 'modifiedBy',
fieldtype: 'Data',
required: 1
},
{
fieldname: 'creation',
fieldtype: 'Datetime',
required: 1
},
{
fieldname: 'modified',
fieldtype: 'Datetime',
required: 1
},
{
fieldname: 'keywords',
fieldtype: 'Text'
}
],
childFields: [
{
fieldname: 'idx',
fieldtype: 'Int',
required: 1
},
{
fieldname: 'parent',
fieldtype: 'Data',
required: 1
},
{
fieldname: 'parenttype',
fieldtype: 'Data',
required: 1
},
{
fieldname: 'parentfield',
fieldtype: 'Data',
required: 1
}
],
treeFields: [
{
fieldname: 'lft',
fieldtype: 'Int'
},
{
fieldname: 'rgt',
fieldtype: 'Int'
}
]
};

View File

@ -1,334 +0,0 @@
import frappe from 'frappe';
import { validTypes } from 'frappe/backends/helpers';
import { ValueError } from 'frappe/utils/errors';
import { indicators as indicatorColor } from '../../src/colors';
import { t } from '../utils/translation';
import Document from './document';
import model from './index';
export default class Meta extends Document {
filters;
basedOn;
constructor(data) {
if (data.basedOn) {
let config = frappe.models[data.basedOn];
Object.assign(data, config, {
name: data.name,
label: data.label,
filters: data.filters,
});
}
super(data);
this.setDefaultIndicators();
if (this.setupMeta) {
this.setupMeta();
}
if (!this.titleField) {
this.titleField = 'name';
}
}
setValues(data) {
Object.assign(this, data);
this.processFields();
}
processFields() {
// add name field
if (!this.fields.find((df) => df.fieldname === 'name') && !this.isSingle) {
this.fields = [
{
label: t`ID`,
fieldname: 'name',
fieldtype: 'Data',
required: 1,
readOnly: 1,
},
].concat(this.fields);
}
this.fields = this.fields.map((df) => {
// name field is always required
if (df.fieldname === 'name') {
df.required = 1;
}
return df;
});
}
hasField(fieldname) {
return this.getField(fieldname) ? true : false;
}
getField(fieldname) {
if (!this._field_map) {
this._field_map = {};
for (let field of this.fields) {
this._field_map[field.fieldname] = field;
}
}
return this._field_map[fieldname];
}
/**
* Get fields filtered by filters
* @param {Object} filters
*
* Usage:
* meta = frappe.getMeta('ToDo')
* dataFields = meta.getFieldsWith({ fieldtype: 'Data' })
*/
getFieldsWith(filters) {
return this.fields.filter((df) => {
let match = true;
for (const key in filters) {
const value = filters[key];
match = df[key] === value;
}
return match;
});
}
getLabel(fieldname) {
let df = this.getField(fieldname);
return df.getLabel || df.label;
}
getTableFields() {
if (this._tableFields === undefined) {
this._tableFields = this.fields.filter(
(field) => field.fieldtype === 'Table'
);
}
return this._tableFields;
}
getFormulaFields() {
if (this._formulaFields === undefined) {
this._formulaFields = this.fields.filter((field) => field.formula);
}
return this._formulaFields;
}
hasFormula() {
if (this._hasFormula === undefined) {
this._hasFormula = false;
if (this.getFormulaFields().length) {
this._hasFormula = true;
} else {
for (let tablefield of this.getTableFields()) {
if (frappe.getMeta(tablefield.childtype).getFormulaFields().length) {
this._hasFormula = true;
break;
}
}
}
}
return this._hasFormula;
}
getBaseDocType() {
return this.basedOn || this.name;
}
async set(fieldname, value) {
this[fieldname] = value;
await this.trigger(fieldname);
}
get(fieldname) {
return this[fieldname];
}
getValidFields({ withChildren = true } = {}) {
if (!this._validFields) {
this._validFields = [];
this._validFieldsWithChildren = [];
const _add = (field) => {
this._validFields.push(field);
this._validFieldsWithChildren.push(field);
};
// fields validation
this.fields.forEach((df, i) => {
if (!df.fieldname) {
throw new ValidationError(
`DocType ${this.name}: "fieldname" is required for field at index ${i}`
);
}
if (!df.fieldtype) {
throw new ValidationError(
`DocType ${this.name}: "fieldtype" is required for field "${df.fieldname}"`
);
}
});
const doctypeFields = this.fields.map((field) => field.fieldname);
// standard fields
for (let field of model.commonFields) {
if (
validTypes.includes(field.fieldtype) &&
!doctypeFields.includes(field.fieldname)
) {
_add(field);
}
}
if (this.isSubmittable) {
_add({
fieldtype: 'Check',
fieldname: 'submitted',
label: t`Submitted`,
});
}
if (this.isChild) {
// child fields
for (let field of model.childFields) {
if (
validTypes.includes(field.fieldtype) &&
!doctypeFields.includes(field.fieldname)
) {
_add(field);
}
}
} else {
// parent fields
for (let field of model.parentFields) {
if (
validTypes.includes(field.fieldtype) &&
!doctypeFields.includes(field.fieldname)
) {
_add(field);
}
}
}
if (this.isTree) {
// tree fields
for (let field of model.treeFields) {
if (
validTypes.includes(field.fieldtype) &&
!doctypeFields.includes(field.fieldname)
) {
_add(field);
}
}
}
// doctype fields
for (let field of this.fields) {
const include = validTypes.includes(field.fieldtype);
if (include) {
_add(field);
}
// include tables if (withChildren = True)
if (!include && field.fieldtype === 'Table') {
this._validFieldsWithChildren.push(field);
}
}
}
if (withChildren) {
return this._validFieldsWithChildren;
} else {
return this._validFields;
}
}
getKeywordFields() {
if (!this._keywordFields) {
this._keywordFields = this.keywordFields;
if (!(this._keywordFields && this._keywordFields.length && this.fields)) {
this._keywordFields = this.fields
.filter((field) => field.fieldtype !== 'Table' && field.required)
.map((field) => field.fieldname);
}
if (!(this._keywordFields && this._keywordFields.length)) {
this._keywordFields = ['name'];
}
}
return this._keywordFields;
}
getQuickEditFields() {
if (this.quickEditFields) {
return this.quickEditFields.map((fieldname) => this.getField(fieldname));
}
return this.getFieldsWith({ required: 1 });
}
validateSelect(field, value) {
let options = field.options;
if (!options) return;
if (!field.required && value == null) {
return;
}
let validValues = options;
if (typeof options === 'string') {
// values given as string
validValues = options.split('\n');
}
if (typeof options[0] === 'object') {
// options as array of {label, value} pairs
validValues = options.map((o) => o.value);
}
if (!validValues.includes(value)) {
throw new ValueError(
// prettier-ignore
`DocType ${this.name}: Invalid value "${value}" for "${field.label}". Must be one of ${options.join(', ')}`
);
}
return value;
}
async trigger(event, params = {}) {
Object.assign(params, {
doc: this,
name: event,
});
await super.trigger(event, params);
}
setDefaultIndicators() {
if (!this.indicators) {
if (this.isSubmittable) {
this.indicators = {
key: 'submitted',
colors: {
0: indicatorColor.GRAY,
1: indicatorColor.BLUE,
},
};
}
}
}
getIndicatorColor(doc) {
if (frappe.isDirty(this.name, doc.name)) {
return indicatorColor.ORANGE;
} else {
if (this.indicators) {
let value = doc[this.indicators.key];
if (value) {
return this.indicators.colors[value] || indicatorColor.GRAY;
} else {
return indicatorColor.GRAY;
}
} else {
return indicatorColor.GRAY;
}
}
}
}

View File

@ -1,8 +1,8 @@
import frappe from 'frappe';
import { getRandomString } from 'frappe/utils';
export async function isNameAutoSet(doctype) {
const doc = frappe.getEmptyDoc(doctype);
export async function isNameAutoSet(schemaName: string) {
const doc = frappe.doc.getEmptyDoc(schemaName);
if (doc.meta.naming === 'autoincrement') {
return true;
}

View File

@ -1,26 +0,0 @@
import frappe from 'frappe';
export default async function runPatches(patchList) {
const patchesAlreadyRun = (
await frappe.db.knex('PatchRun').select('name')
).map(({ name }) => name);
for (let patch of patchList) {
if (patchesAlreadyRun.includes(patch.patchName)) {
continue;
}
await runPatch(patch);
}
}
async function runPatch({ patchName, patchFunction }) {
try {
await patchFunction();
const patchRun = frappe.getEmptyDoc('PatchRun');
patchRun.name = patchName;
await patchRun.insert();
} catch (error) {
console.error(`could not run ${patchName}`, error);
}
}

31
frappe/model/types.ts Normal file
View File

@ -0,0 +1,31 @@
import { DocValue } from 'frappe/core/types';
import Doc from './doc';
/**
* The functions below are used for dynamic evaluation
* and setting of field types.
*
* Since they are set directly on the doc, they can
* access the doc by using `this`.
*
* - `Formula`: Async function used for obtaining a computed value such as amount (rate * qty).
* - `Default`: Regular function used to dynamically set the default value, example new Date().
* - `Validation`: Async function that throw an error if the value is invalid.
* - `Required`: Regular function used to decide if a value is mandatory (there are !notnul in the db).
*/
export type Formula = () => Promise<DocValue>;
export type Default = () => DocValue;
export type Validation = (value: DocValue) => Promise<void>;
export type Required = () => boolean;
export type FormulaMap = Record<string, Formula | undefined>;
export type DefaultMap = Record<string, Default | undefined>;
export type ValidationMap = Record<string, Validation | undefined>;
export type RequiredMap = Record<string, Required | undefined>;
export type DependsOnMap = Record<string, string[]>
/**
* Should add this for hidden too
*/

View File

@ -0,0 +1,39 @@
import { ValidationError, ValueError } from 'frappe/utils/errors';
import { t } from 'frappe/utils/translation';
import { OptionField } from 'schemas/types';
export function email(value: string) {
const isValid = /(.+)@(.+){2,}\.(.+){2,}/.test(value);
if (!isValid) {
throw new ValidationError(`Invalid email: ${value}`);
}
}
export function phone(value: string) {
const isValid = /[+]{0,1}[\d ]+/.test(value);
if (!isValid) {
throw new ValidationError(`Invalid phone: ${value}`);
}
}
export function validateSelect(field: OptionField, value: string) {
const options = field.options;
if (!options) {
return;
}
if (!field.required && (value === null || value === undefined)) {
return;
}
const validValues = options.map((o) => o.value);
if (validValues.includes(value)) {
return;
}
const labels = options.map((o) => o.label).join(', ');
throw new ValueError(
t`Invalid value ${value} for ${field.label}. Must be one of ${labels}`
);
}

View File

@ -4,7 +4,7 @@ enum EventType {
}
export default class Observable<T> {
[key: string]: unknown;
[key: string]: unknown | T;
_isHot: Map<string, boolean>;
_eventQueue: Map<string, unknown[]>;
_map: Map<string, unknown>;
@ -25,8 +25,8 @@ export default class Observable<T> {
* @param key
* @returns
*/
get(key: string): unknown {
return this[key];
get(key: string): T {
return this[key] as T;
}
/**
@ -35,7 +35,7 @@ export default class Observable<T> {
* @param key
* @param value
*/
set(key: string, value: unknown) {
set(key: string, value: T) {
this[key] = value;
this.trigger('change', {
doc: this,

View File

@ -199,9 +199,10 @@ export default class PaymentServer extends Document {
async updateReferenceOutstandingAmount() {
await this.for.forEach(async ({ amount, referenceType, referenceName }) => {
const refDoc = await frappe.getDoc(referenceType, referenceName);
refDoc.update({
refDoc.setMultiple({
outstandingAmount: refDoc.outstandingAmount.add(amount),
});
refDoc.update();
});
}
}

View File

@ -35,7 +35,8 @@
"label": "Address",
"fieldtype": "Link",
"target": "Address",
"placeholder": "Click to create"
"placeholder": "Click to create",
"inline": true
},
{
"fieldname": "template",

View File

@ -1,19 +0,0 @@
export function getMapFromList<T, K extends keyof T>(
list: T[],
name: K
): Record<string, T> {
const acc: Record<string, T> = {};
for (const t of list) {
const key = t[name];
if (key === undefined) {
continue;
}
acc[String(key)] = t;
}
return acc;
}
export function getListFromMap<T>(map: Record<string, T>): T[] {
return Object.keys(map).map((n) => map[n]);
}

View File

@ -1,5 +1,5 @@
import { cloneDeep } from 'lodash';
import { getListFromMap, getMapFromList } from './helpers';
import { getListFromMap, getMapFromList } from 'utils';
import regionalSchemas from './regional';
import { appSchemas, coreSchemas, metaSchemas } from './schemas';
import { Field, Schema, SchemaMap, SchemaStub, SchemaStubMap } from './types';

View File

@ -1,7 +1,7 @@
import * as assert from 'assert';
import { cloneDeep, isEqual } from 'lodash';
import { describe } from 'mocha';
import { getMapFromList } from '../helpers';
import { getMapFromList } from 'utils';
import {
addMetaFields,
cleanSchemas,

View File

@ -59,7 +59,6 @@ export enum FieldTypeEnum {
export type FieldType = keyof typeof FieldTypeEnum;
export type RawValue = string | number | boolean | null;
// prettier-ignore
export interface BaseField {
fieldname: string; // Column name in the db
fieldtype: FieldType; // UI Descriptive field types that map to column types
@ -73,6 +72,7 @@ export interface BaseField {
groupBy?: string; // UI Facing used in dropdowns fields
computed?: boolean; // Indicates whether a value is computed, implies readonly
meta?: boolean; // Field is a meta field, i.e. only for the db, not UI
inline?: boolean; // UI Facing config, whether to display doc inline.
}
export type SelectOption = { value: string; label: string };
@ -84,19 +84,16 @@ export interface OptionField extends BaseField {
options: SelectOption[];
}
// prettier-ignore
export interface TargetField extends BaseField {
fieldtype: FieldTypeEnum.Table | FieldTypeEnum.Link;
target: string; // Name of the table or group of tables to fetch values
}
// @formatter:off
export interface DynamicLinkField extends BaseField {
fieldtype: FieldTypeEnum.DynamicLink;
references: string; // Reference to an option field that links to schema
}
// @formatter:off
export interface NumberField extends BaseField {
fieldtype: FieldTypeEnum.Float | FieldTypeEnum.Int;
minvalue?: number; // UI Facing used to restrict lower bound
@ -112,7 +109,6 @@ export type Field =
export type TreeSettings = { parentField: string };
// @formatter:off
export interface Schema {
name: string; // Table name
label: string; // Translateable UI facing name

View File

@ -87,10 +87,10 @@
import Button from '@/components/Button';
import Icon from '@/components/Icon';
import PageHeader from '@/components/PageHeader';
import { IPC_MESSAGES } from 'utils/messages';
import { openSettings, routeTo } from '@/utils';
import { ipcRenderer } from 'electron';
import frappe, { t } from 'frappe';
import { IPC_MESSAGES } from 'utils/messages';
import { h } from 'vue';
export default {
@ -112,8 +112,7 @@ export default {
key: 'Invoice',
label: t`Invoice`,
icon: 'invoice',
description:
t`Customize your invoices by adding a logo and address details`,
description: t`Customize your invoices by adding a logo and address details`,
fieldname: 'invoiceSetup',
action() {
openSettings('Invoice');
@ -123,8 +122,7 @@ export default {
key: 'General',
label: t`General`,
icon: 'general',
description:
t`Setup your company information, email, country and fiscal year`,
description: t`Setup your company information, email, country and fiscal year`,
fieldname: 'companySetup',
action() {
openSettings('General');
@ -134,8 +132,7 @@ export default {
key: 'System',
label: t`System`,
icon: 'system',
description:
t`Setup system defaults like date format and display precision`,
description: t`Setup system defaults like date format and display precision`,
fieldname: 'systemSetup',
action() {
openSettings('System');
@ -151,8 +148,7 @@ export default {
key: 'Review Accounts',
label: t`Review Accounts`,
icon: 'review-ac',
description:
t`Review your chart of accounts, add any account or tax heads as needed`,
description: t`Review your chart of accounts, add any account or tax heads as needed`,
action: () => {
routeTo('/chart-of-accounts');
},
@ -165,8 +161,7 @@ export default {
label: t`Opening Balances`,
icon: 'opening-ac',
fieldname: 'openingBalanceChecked',
description:
t`Setup your opening balances before performing any accounting entries`,
description: t`Setup your opening balances before performing any accounting entries`,
documentation:
'https://frappebooks.com/docs/setting-up#5-setup-opening-balances',
},
@ -175,8 +170,7 @@ export default {
label: t`Add Taxes`,
icon: 'percentage',
fieldname: 'taxesAdded',
description:
t`Setup your tax templates for your sales or purchase transactions`,
description: t`Setup your tax templates for your sales or purchase transactions`,
action: () => routeTo('/list/Tax'),
documentation:
'https://frappebooks.com/docs/setting-up#2-add-taxes',
@ -191,8 +185,7 @@ export default {
key: 'Add Sales Items',
label: t`Add Items`,
icon: 'item',
description:
t`Add products or services that you sell to your customers`,
description: t`Add products or services that you sell to your customers`,
action: () => routeTo('/list/Item'),
fieldname: 'itemCreated',
documentation:
@ -212,8 +205,7 @@ export default {
key: 'Create Invoice',
label: t`Create Invoice`,
icon: 'sales-invoice',
description:
t`Create your first invoice and mail it to your customer`,
description: t`Create your first invoice and mail it to your customer`,
action: () => routeTo('/list/SalesInvoice'),
fieldname: 'invoiceCreated',
documentation: 'https://frappebooks.com/docs/invoices',
@ -228,8 +220,7 @@ export default {
key: 'Add Purchase Items',
label: t`Add Items`,
icon: 'item',
description:
t`Add products or services that you buy from your suppliers`,
description: t`Add products or services that you buy from your suppliers`,
action: () => routeTo('/list/Item'),
fieldname: 'itemCreated',
},
@ -245,8 +236,7 @@ export default {
key: 'Create Bill',
label: t`Create Bill`,
icon: 'purchase-invoice',
description:
t`Create your first bill and mail it to your supplier`,
description: t`Create your first bill and mail it to your supplier`,
action: () => routeTo('/list/PurchaseInvoice'),
fieldname: 'billCreated',
documentation: 'https://frappebooks.com/docs/bills',
@ -316,7 +306,8 @@ export default {
if (onboardingComplete) {
await this.updateChecks({ onboardingComplete });
const systemSettings = await frappe.getSingle('SystemSettings');
await systemSettings.update({ hideGetStarted: 1 });
await systemSettings.set({ hideGetStarted: 1 });
await systemSettings.update();
}
return onboardingComplete;
@ -380,7 +371,8 @@ export default {
await this.updateChecks(toUpdate);
},
async updateChecks(toUpdate) {
await frappe.GetStarted.update(toUpdate);
await frappe.GetStarted.setMultiple(toUpdate);
await frappe.GetStarted.update();
frappe.GetStarted = await frappe.getSingle('GetStarted');
},
isCompleted(item) {

View File

@ -27,7 +27,7 @@ export default async function setupCompany(setupWizardValues) {
const locale = countryList[country]['locale'] ?? DEFAULT_LOCALE;
await callInitializeMoneyMaker(currency);
await accountingSettings.update({
const accountingSettingsUpdateMap = {
companyName,
country,
fullname: name,
@ -36,25 +36,34 @@ export default async function setupCompany(setupWizardValues) {
fiscalYearStart,
fiscalYearEnd,
currency,
});
};
await accountingSettings.setMultiple(accountingSettingsUpdateMap);
await accountingSettings.update();
const printSettings = await frappe.getSingle('PrintSettings');
printSettings.update({
const printSettingsUpdateMap = {
logo: companyLogo,
companyName,
email,
displayLogo: companyLogo ? 1 : 0,
});
displayLogo: companyLogo ? true : false,
};
await printSettings.setMultiple(printSettingsUpdateMap);
await printSettings.update();
await setupGlobalCurrencies(countryList);
await setupChartOfAccounts(bankName, country, chartOfAccounts);
await setupRegionalChanges(country);
updateInitializationConfig();
await accountingSettings.update({ setupComplete: 1 });
await accountingSettings.setMultiple({ setupComplete: true });
await accountingSettings.update();
frappe.AccountingSettings = accountingSettings;
(await frappe.getSingle('SystemSettings')).update({ locale });
const systemSettings = await frappe.getSingle('SystemSettings');
systemSettings.setMultiple({ locale });
systemSettings.update();
}
async function setupGlobalCurrencies(countries) {

View File

@ -31,3 +31,27 @@ export function getRandomString(): string {
export async function sleep(durationMilliseconds: number = 1000) {
return new Promise((r) => setTimeout(() => r(null), durationMilliseconds));
}
export function getMapFromList<T, K extends keyof T>(
list: T[],
name: K
): Record<string, T> {
const acc: Record<string, T> = {};
for (const t of list) {
const key = t[name];
if (key === undefined) {
continue;
}
acc[String(key)] = t;
}
return acc;
}
export function getListFromMap<T>(map: Record<string, T>): T[] {
return Object.keys(map).map((n) => map[n]);
}
export function getIsNullOrUndef(value: unknown): boolean {
return value === null || value === undefined;
}