diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..d89325b6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +**/types.ts \ No newline at end of file diff --git a/backend/database/tests/testCore.spec.ts b/backend/database/tests/testCore.spec.ts index 2b1ddd80..c91dd2e1 100644 --- a/backend/database/tests/testCore.spec.ts +++ b/backend/database/tests/testCore.spec.ts @@ -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'; diff --git a/frappe/backends/database.js b/frappe/backends/database.js deleted file mode 100644 index d0951e00..00000000 --- a/frappe/backends/database.js +++ /dev/null @@ -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} 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(); - } -} diff --git a/frappe/backends/helpers.ts b/frappe/backends/helpers.ts deleted file mode 100644 index 1d2d38a4..00000000 --- a/frappe/backends/helpers.ts +++ /dev/null @@ -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); diff --git a/frappe/backends/sqlite.js b/frappe/backends/sqlite.js deleted file mode 100644 index f9f8a58a..00000000 --- a/frappe/backends/sqlite.js +++ /dev/null @@ -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'); - } -} diff --git a/frappe/core/authHandler.ts b/frappe/core/authHandler.ts index 05786be1..d1f727c0 100644 --- a/frappe/core/authHandler.ts +++ b/frappe/core/authHandler.ts @@ -1,4 +1,4 @@ -import { Frappe } from 'frappe/core/frappe'; +import { Frappe } from 'frappe'; interface AuthConfig { serverURL: string; diff --git a/frappe/core/dbHandler.ts b/frappe/core/dbHandler.ts index 5957b903..3e78b453 100644 --- a/frappe/core/dbHandler.ts +++ b/frappe/core/dbHandler.ts @@ -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'; diff --git a/frappe/core/docHandler.ts b/frappe/core/docHandler.ts index ad3b6a66..ecdfdbae 100644 --- a/frappe/core/docHandler.ts +++ b/frappe/core/docHandler.ts @@ -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; type MetaMap = Record; @@ -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 {} } diff --git a/frappe/core/types.ts b/frappe/core/types.ts index 4a8beaa6..532c7f50 100644 --- a/frappe/core/types.ts +++ b/frappe/core/types.ts @@ -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; +export type DocValue = string | number | boolean | Date | Money | null; +export type DocValueMap = Record; export type RawValueMap = Record; export type SingleValue = { diff --git a/frappe/index.js b/frappe/index.js deleted file mode 100644 index 314034aa..00000000 --- a/frappe/index.js +++ /dev/null @@ -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(); diff --git a/frappe/core/frappe.ts b/frappe/index.ts similarity index 82% rename from frappe/core/frappe.ts rename to frappe/index.ts index a884b35d..e998d74a 100644 --- a/frappe/core/frappe.ts +++ b/frappe/index.ts @@ -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); diff --git a/frappe/model/doc.ts b/frappe/model/doc.ts new file mode 100644 index 00000000..17327e63 --- /dev/null +++ b/frappe/model/doc.ts @@ -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 { + name?: string; + schema: Readonly; + fieldMap: Record; + + /** + * Fields below are used by child docs to maintain + * reference w.r.t their parent doc. + */ + idx?: number; + parentdoc?: Doc; + parentfield?: string; + + _links?: Record; + _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 = 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 = {}; +} diff --git a/frappe/model/helpers.ts b/frappe/model/helpers.ts new file mode 100644 index 00000000..7a2c7088 --- /dev/null +++ b/frappe/model/helpers.ts @@ -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); +} diff --git a/frappe/model/index.js b/frappe/model/index.js deleted file mode 100644 index 415a5005..00000000 --- a/frappe/model/index.js +++ /dev/null @@ -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' - } - ] -}; diff --git a/frappe/model/meta.js b/frappe/model/meta.js deleted file mode 100644 index 9dd9153c..00000000 --- a/frappe/model/meta.js +++ /dev/null @@ -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; - } - } - } -} diff --git a/frappe/model/naming.js b/frappe/model/naming.ts similarity index 95% rename from frappe/model/naming.js rename to frappe/model/naming.ts index 11646197..45c5d50d 100644 --- a/frappe/model/naming.js +++ b/frappe/model/naming.ts @@ -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; } diff --git a/frappe/model/runPatches.js b/frappe/model/runPatches.js deleted file mode 100644 index b0bebb7b..00000000 --- a/frappe/model/runPatches.js +++ /dev/null @@ -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); - } -} diff --git a/frappe/model/types.ts b/frappe/model/types.ts new file mode 100644 index 00000000..ecac74cd --- /dev/null +++ b/frappe/model/types.ts @@ -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; +export type Default = () => DocValue; +export type Validation = (value: DocValue) => Promise; +export type Required = () => boolean; + +export type FormulaMap = Record; +export type DefaultMap = Record; +export type ValidationMap = Record; +export type RequiredMap = Record; + +export type DependsOnMap = Record + +/** + * Should add this for hidden too + + */ diff --git a/frappe/model/validationFunction.ts b/frappe/model/validationFunction.ts new file mode 100644 index 00000000..91703d32 --- /dev/null +++ b/frappe/model/validationFunction.ts @@ -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}` + ); +} diff --git a/frappe/utils/observable.ts b/frappe/utils/observable.ts index 5583c3eb..61c8f998 100644 --- a/frappe/utils/observable.ts +++ b/frappe/utils/observable.ts @@ -4,7 +4,7 @@ enum EventType { } export default class Observable { - [key: string]: unknown; + [key: string]: unknown | T; _isHot: Map; _eventQueue: Map; _map: Map; @@ -25,8 +25,8 @@ export default class Observable { * @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 { * @param key * @param value */ - set(key: string, value: unknown) { + set(key: string, value: T) { this[key] = value; this.trigger('change', { doc: this, diff --git a/models/doctype/Payment/PaymentServer.js b/models/doctype/Payment/PaymentServer.js index 4f4e6f19..a9713d8f 100644 --- a/models/doctype/Payment/PaymentServer.js +++ b/models/doctype/Payment/PaymentServer.js @@ -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(); }); } } diff --git a/schemas/app/PrintSettings.json b/schemas/app/PrintSettings.json index 7c2570b3..f8fa835b 100644 --- a/schemas/app/PrintSettings.json +++ b/schemas/app/PrintSettings.json @@ -35,7 +35,8 @@ "label": "Address", "fieldtype": "Link", "target": "Address", - "placeholder": "Click to create" + "placeholder": "Click to create", + "inline": true }, { "fieldname": "template", diff --git a/schemas/helpers.ts b/schemas/helpers.ts deleted file mode 100644 index 93fce200..00000000 --- a/schemas/helpers.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function getMapFromList( - list: T[], - name: K -): Record { - const acc: Record = {}; - for (const t of list) { - const key = t[name]; - if (key === undefined) { - continue; - } - - acc[String(key)] = t; - } - return acc; -} - -export function getListFromMap(map: Record): T[] { - return Object.keys(map).map((n) => map[n]); -} diff --git a/schemas/index.ts b/schemas/index.ts index 10d89ac6..158279d0 100644 --- a/schemas/index.ts +++ b/schemas/index.ts @@ -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'; diff --git a/schemas/tests/testSchemaBuilder.spec.ts b/schemas/tests/testSchemaBuilder.spec.ts index eeabafb9..98e08a56 100644 --- a/schemas/tests/testSchemaBuilder.spec.ts +++ b/schemas/tests/testSchemaBuilder.spec.ts @@ -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, diff --git a/schemas/types.ts b/schemas/types.ts index d3de2233..5fd297c0 100644 --- a/schemas/types.ts +++ b/schemas/types.ts @@ -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 diff --git a/src/pages/GetStarted.vue b/src/pages/GetStarted.vue index 167e2da1..c6152834 100644 --- a/src/pages/GetStarted.vue +++ b/src/pages/GetStarted.vue @@ -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) { diff --git a/src/pages/SetupWizard/setupCompany.js b/src/pages/SetupWizard/setupCompany.js index 04df9f06..a8238f76 100644 --- a/src/pages/SetupWizard/setupCompany.js +++ b/src/pages/SetupWizard/setupCompany.js @@ -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) { diff --git a/utils/index.ts b/utils/index.ts index 2bef4b69..6b1b3d62 100644 --- a/utils/index.ts +++ b/utils/index.ts @@ -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( + list: T[], + name: K +): Record { + const acc: Record = {}; + for (const t of list) { + const key = t[name]; + if (key === undefined) { + continue; + } + + acc[String(key)] = t; + } + return acc; +} + +export function getListFromMap(map: Record): T[] { + return Object.keys(map).map((n) => map[n]); +} + +export function getIsNullOrUndef(value: unknown): boolean { + return value === null || value === undefined; +}