From bf10927be99b2cf5ca03e0f572d4069819fa94b0 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Thu, 3 Aug 2023 13:18:14 +0530 Subject: [PATCH] feat: enable custom fields - fix date issue in setup test --- backend/database/manager.ts | 18 +++++-- backend/database/types.ts | 19 +++++-- fyo/model/doc.ts | 2 +- fyo/models/CustomField.ts | 66 ++++++++++++++++++++++--- fyo/models/CustomForm.ts | 30 +++++++++++- fyo/tests/testFyo.spec.ts | 2 +- schemas/index.ts | 87 ++++++++++++++++++++++++++++++++- tests/helpers.ts | 9 +++- tests/testSetupInstance.spec.ts | 3 +- 9 files changed, 213 insertions(+), 23 deletions(-) diff --git a/backend/database/manager.ts b/backend/database/manager.ts index a2b5f24f..1c045afb 100644 --- a/backend/database/manager.ts +++ b/backend/database/manager.ts @@ -11,10 +11,11 @@ import patches from '../patches'; import { BespokeQueries } from './bespoke'; import DatabaseCore from './core'; import { runPatches } from './runPatch'; -import { BespokeFunction, Patch } from './types'; +import { BespokeFunction, Patch, RawCustomField } from './types'; export class DatabaseManager extends DatabaseDemuxBase { db?: DatabaseCore; + rawCustomFields: RawCustomField[] = []; get #isInitialized(): boolean { return this.db !== undefined && this.db.knex !== undefined; @@ -22,10 +23,10 @@ export class DatabaseManager extends DatabaseDemuxBase { getSchemaMap() { if (this.#isInitialized) { - return this.db?.schemaMap ?? getSchemas(); + return this.db?.schemaMap ?? getSchemas('-', this.rawCustomFields); } - return getSchemas(); + return getSchemas('-', this.rawCustomFields); } async createNewDatabase(dbPath: string, countryCode: string) { @@ -43,11 +44,20 @@ export class DatabaseManager extends DatabaseDemuxBase { countryCode ??= await DatabaseCore.getCountryCode(dbPath); this.db = new DatabaseCore(dbPath); await this.db.connect(); - const schemaMap = getSchemas(countryCode); + await this.setRawCustomFields(); + const schemaMap = getSchemas(countryCode, this.rawCustomFields); this.db.setSchemaMap(schemaMap); return countryCode; } + async setRawCustomFields() { + try { + this.rawCustomFields = (await this.db?.knex?.( + 'CustomField' + )) as RawCustomField[]; + } catch {} + } + async #migrate(): Promise { if (!this.#isInitialized) { return; diff --git a/backend/database/types.ts b/backend/database/types.ts index b4a98419..14d4301b 100644 --- a/backend/database/types.ts +++ b/backend/database/types.ts @@ -1,6 +1,6 @@ -import { Field, RawValue } from '../../schemas/types'; -import DatabaseCore from './core'; -import { DatabaseManager } from './manager'; +import type { Field, FieldType, RawValue } from '../../schemas/types'; +import type DatabaseCore from './core'; +import type { DatabaseManager } from './manager'; export interface GetQueryBuilderOptions { offset?: number; @@ -80,3 +80,16 @@ export type SingleValue = { parent: string; value: T; }[]; + +export type RawCustomField = { + parent: string; + label: string; + fieldname: string; + fieldtype: FieldType; + isRequired?: boolean; + section?: string; + tab?: string; + options?: string; + target?: string; + references?: string; +}; diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index f9f5e2bb..605194f5 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -751,7 +751,7 @@ export class Doc extends Observable { if (dbValues && docModified !== dbModified) { throw new ConflictError( this.fyo - .t`${this.schema.label} ${this.name} has been modified after loading` + + .t`${this.schema.label} ${this.name} has been modified after loading please reload entry.` + ` ${dbModified}, ${docModified}` ); } diff --git a/fyo/models/CustomField.ts b/fyo/models/CustomField.ts index f576e085..d280f320 100644 --- a/fyo/models/CustomField.ts +++ b/fyo/models/CustomField.ts @@ -1,14 +1,18 @@ +import { DocValue } from 'fyo/core/types'; import { Doc } from 'fyo/model/doc'; import type { FormulaMap, HiddenMap, ListsMap, + RequiredMap, ValidationMap, } from 'fyo/model/types'; -import type { FieldType, SelectOption } from 'schemas/types'; -import type { CustomForm } from './CustomForm'; import { ValueError } from 'fyo/utils/errors'; +import { camelCase } from 'lodash'; import { ModelNameEnum } from 'models/types'; +import type { FieldType } from 'schemas/types'; +import { FieldTypeEnum } from 'schemas/types'; +import type { CustomForm } from './CustomForm'; export class CustomField extends Doc { parentdoc?: CustomForm; @@ -31,6 +35,19 @@ export class CustomField extends Doc { return this.parentdoc?.parentFields; } + formulas: FormulaMap = { + fieldname: { + formula: () => { + if (!this.label?.length) { + return; + } + + return camelCase(this.label); + }, + dependsOn: ['label'], + }, + }; + hidden: HiddenMap = { options: () => this.fieldtype !== 'Select' && @@ -40,9 +57,15 @@ export class CustomField extends Doc { references: () => this.fieldtype !== 'DynamicLink', }; - formulas: FormulaMap = {}; - validations: ValidationMap = { + label: (value) => { + if (typeof value !== 'string') { + return; + } + + const fieldname = camelCase(value); + (this.validations.fieldname as (value: DocValue) => void)(fieldname); + }, fieldname: (value) => { if (typeof value !== 'string') { return; @@ -94,9 +117,38 @@ export class CustomField extends Doc { return []; } - return (doc.parentdoc?.customFields - ?.map((cf) => ({ value: cf.fieldname, label: cf.label })) - .filter((cf) => cf.label && cf.value) ?? []) as SelectOption[]; + const referenceType: string[] = [ + FieldTypeEnum.AutoComplete, + FieldTypeEnum.Data, + FieldTypeEnum.Text, + FieldTypeEnum.Select, + ]; + + const customFields = + doc.parentdoc?.customFields + ?.filter( + (cf) => + cf.fieldname && + cf.label && + referenceType.includes(cf.fieldtype ?? '') + ) + ?.map((cf) => ({ value: cf.fieldname!, label: cf.label! })) ?? []; + + const schemaFields = + doc.parentSchema?.fields + .filter( + (f) => f.fieldname && f.label && referenceType.includes(f.fieldtype) + ) + .map((f) => ({ value: f.fieldname, label: f.label })) ?? []; + + return [customFields, schemaFields].flat(); }, }; + + required: RequiredMap = { + options: () => + this.fieldtype === 'Select' || this.fieldtype === 'AutoComplete', + target: () => this.fieldtype === 'Link' || this.fieldtype === 'Table', + references: () => this.fieldtype === 'DynamicLink', + }; } diff --git a/fyo/models/CustomForm.ts b/fyo/models/CustomForm.ts index fc429876..f062360c 100644 --- a/fyo/models/CustomForm.ts +++ b/fyo/models/CustomForm.ts @@ -1,9 +1,10 @@ import { Doc } from 'fyo/model/doc'; import { HiddenMap, ListsMap } from 'fyo/model/types'; +import { ValidationError } from 'fyo/utils/errors'; import { ModelNameEnum } from 'models/types'; -import type { CustomField } from './CustomField'; -import { getMapFromList } from 'utils/index'; import { Field } from 'schemas/types'; +import { getMapFromList } from 'utils/index'; +import { CustomField } from './CustomField'; export class CustomForm extends Doc { name?: string; @@ -49,4 +50,29 @@ export class CustomForm extends Doc { }; hidden: HiddenMap = { customFields: () => !this.name }; + + // eslint-disable-next-line @typescript-eslint/require-await + override async validate(): Promise { + for (const row of this.customFields ?? []) { + if (row.fieldtype === 'Select' || row.fieldtype === 'AutoComplete') { + this.validateOptions(row); + } + } + } + + validateOptions(row: CustomField) { + const optionString = row.options ?? ''; + const options = optionString + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + + if (options.length > 1) { + return; + } + + throw new ValidationError( + `At least two options need to be set for the selected fieldtype` + ); + } } diff --git a/fyo/tests/testFyo.spec.ts b/fyo/tests/testFyo.spec.ts index 6c4208f7..bc6d5002 100644 --- a/fyo/tests/testFyo.spec.ts +++ b/fyo/tests/testFyo.spec.ts @@ -17,7 +17,7 @@ test('Fyo Init', async (t) => { test('Fyo Docs', async (t) => { const countryCode = 'in'; const fyo = getTestFyo(); - const schemaMap = getSchemas(countryCode); + const schemaMap = getSchemas(countryCode, []); const regionalModels = await getRegionalModels(countryCode); await fyo.db.createNewDatabase(':memory:', countryCode); await fyo.initializeAndRegister(models, regionalModels); diff --git a/schemas/index.ts b/schemas/index.ts index 37ab4f72..0aecbd0e 100644 --- a/schemas/index.ts +++ b/schemas/index.ts @@ -1,8 +1,16 @@ +import { RawCustomField } from 'backend/database/types'; import { cloneDeep } from 'lodash'; import { getListFromMap, getMapFromList } from 'utils'; import regionalSchemas from './regional'; import { appSchemas, coreSchemas, metaSchemas } from './schemas'; -import { Field, Schema, SchemaMap, SchemaStub, SchemaStubMap } from './types'; +import type { + Field, + Schema, + SchemaMap, + SchemaStub, + SchemaStubMap, + SelectOption, +} from './types'; const NAME_FIELD = { fieldname: 'name', @@ -12,7 +20,10 @@ const NAME_FIELD = { readOnly: true, }; -export function getSchemas(countryCode = '-'): Readonly { +export function getSchemas( + countryCode = '-', + rawCustomFields: RawCustomField[] +): Readonly { const builtCoreSchemas = getCoreSchemas(); const builtAppSchemas = getAppSchemas(countryCode); @@ -21,6 +32,7 @@ export function getSchemas(countryCode = '-'): Readonly { schemaMap = removeFields(schemaMap); schemaMap = setSchemaNameOnFields(schemaMap); + addCustomFields(schemaMap, rawCustomFields); deepFreeze(schemaMap); return schemaMap; } @@ -251,3 +263,74 @@ function getRegionalSchemaMap(countryCode: string): SchemaStubMap { return getMapFromList(countrySchemas, 'name'); } + +function addCustomFields( + schemaMap: SchemaMap, + rawCustomFields: RawCustomField[] +): void { + const fieldMap = getFieldMapFromRawCustomFields(rawCustomFields, schemaMap); + for (const schemaName in fieldMap) { + const fields = fieldMap[schemaName]; + schemaMap[schemaName]?.fields.push(...fields); + } +} + +function getFieldMapFromRawCustomFields( + rawCustomFields: RawCustomField[], + schemaMap: SchemaMap +) { + const schemaFieldMap: Record> = {}; + + return rawCustomFields.reduce( + ( + map, + { + parent, + label, + fieldname, + fieldtype, + isRequired, + section, + tab, + options: rawOptions, + target, + references, + } + ) => { + schemaFieldMap[parent] ??= getMapFromList( + schemaMap[parent]?.fields ?? [], + 'fieldname' + ); + + if (!schemaFieldMap[parent] || schemaFieldMap[parent][fieldname]) { + return map; + } + + map[parent] ??= []; + const options = rawOptions + ?.split('\n') + .map((o) => { + const value = o.trim(); + return { value, label: value } as SelectOption; + }) + .filter((o) => o.label && o.value); + + const field = { + label, + fieldname, + fieldtype, + required: isRequired, + section, + tab, + target, + options, + references, + isCustom: true, + }; + + map[parent].push(field as Field); + return map; + }, + {} as Record + ); +} diff --git a/tests/helpers.ts b/tests/helpers.ts index aa64d3ab..ff997f33 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -2,6 +2,7 @@ import { DatabaseManager } from 'backend/database/manager'; import { config } from 'dotenv'; import { Fyo } from 'fyo'; import { DummyAuthDemux } from 'fyo/tests/helpers'; +import { DateTime } from 'luxon'; import path from 'path'; import setupInstance from 'src/setup/setupInstance'; import { SetupWizardOptions } from 'src/setup/types'; @@ -17,8 +18,12 @@ export function getTestSetupWizardOptions(): SetupWizardOptions { email: 'test@testmyfantasy.com', bankName: 'Test Bank of Scriptia', currency: 'INR', - fiscalYearStart: getFiscalYear('04-01', true)!.toISOString().split('T')[0], - fiscalYearEnd: getFiscalYear('04-01', false)!.toISOString().split('T')[0], + fiscalYearStart: DateTime.fromJSDate( + getFiscalYear('04-01', true)! + ).toISODate(), + fiscalYearEnd: DateTime.fromJSDate( + getFiscalYear('04-01', false)! + ).toISODate(), chartOfAccounts: 'India - Chart of Accounts', }; } diff --git a/tests/testSetupInstance.spec.ts b/tests/testSetupInstance.spec.ts index b13b1202..02177240 100644 --- a/tests/testSetupInstance.spec.ts +++ b/tests/testSetupInstance.spec.ts @@ -1,4 +1,5 @@ import { assertDoesNotThrow } from 'backend/database/tests/helpers'; +import { DateTime } from 'luxon'; import setupInstance from 'src/setup/setupInstance'; import { SetupWizardOptions } from 'src/setup/types'; import test from 'tape'; @@ -39,7 +40,7 @@ test('check setup Singles', async (t) => { const optionsValue = setupOptions[field as keyof SetupWizardOptions]; if (dbValue instanceof Date) { - dbValue = dbValue.toISOString().split('T')[0]; + dbValue = DateTime.fromJSDate(dbValue).toISODate(); } t.equal(