feat: formula plugin (#1344)

* fix: transaction cannot be rolled back because it has been finished with state: rollback

* fix(plugin-system-settings): convert array to json

* fix(collection-manager): o2m is array type

* fix: missing RefreshActionInitializer

* fix(collection-manger): incorrect scope key parameter

* fix: can't access pages without permission via url (#826)

* feat(database): add sequence field type (#779)

* feat(database): add serialString field type

* feat(database): add serial string type field ui (skip ci)

* test(feat/database): test field options

* docs: demo

* fix(database): fix array table field behavior

* fix(database): fix serial type interface ui

* fix(database): add match logic for patterns changes

* fix(database): fix serial type query last bug in mysql

* refactor(database): refactor last record logic

* chore: revert modification on unnecessary file

* refactor(database): rename serialString type to sequence

Co-authored-by: chenos <chenlinxh@gmail.com>

* added Russian translation (#840)

* Russian translation

* Add files via upload

Add RU locale into index.ts
Bugs fixed in the ru_RU.ts

* Update index.ts

Correct lines 4 and 8

* feat: update option must have filter or filterByTk (#847)

* feat: update option must have filter or filterByTk

* fix: typo

* fix: typo

* feat(core/cache): support cache (#876)

* feat(core/cache): support cache

* build(create-nocobase-app): remove --cache-store-package cli option

* perf(core/cache): modify default cache config and remove unnecessary logic code

* fix: slow join query issued by appends field in find method of repository  (#845)

* fix: slow join query issue by appends field in repository.find

* feat: handle appending query in multiple relation repository

* feat: handle appending query in single relation repository

Co-authored-by: chenos <chenlinxh@gmail.com>

* fix: sort parameter is missing (#849)

* fix: 审计日志翻页sort丢失

* fix: 审计日志翻页sort丢失

* fix: 审计日志翻页sort丢失

Co-authored-by: 唐小爱 <tangxiaoai@192.168.0.103>

* fix(formula): support integer and fix NaN error (#879)

* fix(formula): support integer and fix NaN error

* style(formula-input): remove debugger

* fix(database): fix the index name too long error

* feat(collection-manager): inverse fields can be configured (#883)

* feat: inverse field

* feat: improve code

* feat: translations

* fix: required

* fix: run test by jest (#891)

* fix: unable to submit form during file upload (#892)

* fix(client/block-select-collection): fix select collection menu view error (#889)

* fix(client/block-select-collection): fix too many collection menu view error

* fix(client/relate-collection-field-menu): fix relate collection field menu view too long error

* fix(client/record-picker): support record-picker show format DataPicker (#888)

* fix(client/record-picker): support record-picker show format DataPicker

* fix(client/record-picker): undefined judgment and when change field's label refresh format in time

* feat: improve signin and signup page components

* feat(plugin-workflow): add concat calculator (#894)

* fix: single relation repository appends query issue (#901)

* fix: appends merge includes (#905)

* fix: build error

* fix(client): tab pane initializers for create form block

* fix: version judgment is not accurate

* fix: sync collection field default value (#907)

* feat: limit database identifier (#908)

* fix: cannot read properties of undefined (reading 'target')

* fix: appends merge now using primary key (#911)

* fix: appends merge now using primary key

* chore: console.log

* fix: unbind on error throwing (#914)

* feat: create with array of values (#912)

* feat: create with array of values

* chore: console.log

* chore: debug

* fix(client/route-switch): skip sub routes

* Feat: plugin workflow collection field (#919)

* feat(plugin-workflow): use Collectionfield component to render form

* fix(plugin-workflow): fix association types value assigning in nodes

* fix: missing menuItemGroupCss

* fix: multiple = false

* chore(versions): 😊 publish v0.7.5-alpha.1 (#920)

* fix(plugin-workflow): temp disable validation of collection field in node (#928)

* fix(plugin-workflow): fix schedule infinitely trigger when repeat not set (#926)

* Feat/plugin workflow collection field (#934)

* feat(plugin-workflow): support association constant simple input

* fix(plugin-workflow): remove useless code

* fix(plugin-workflow): add req context to processor (#936)

* fix(plugin-workflow): avoid revision with ghost nodes (#941)

* fix(plugin-workflow): fix collection fieldset component (#942)

* feat(useSignup): customize success message

* feat: changed with associations (#943)

* feat: changed with associations

* fix: test error

* fix: test error

* fix: test

* fix(plugin-workflow): fix schedule trigger bug (#949)

* fix(plugin-workflow): skip time based test (#951)

* fix(plugin-workflow): test changedWithAssociations() (#950)

* fix(plugin-workflow): test changedWithAssociations()

* fix: toChangedWithAssociations

* fix(plugin-workflow): add test cases

Co-authored-by: chenos <chenlinxh@gmail.com>

* feat: docker optimizing (#948)

* chore: feat/docker-optimizing
* feat: docker nginx gzip config

* feat: ui schema cache (#877)

* feat(core/cache): support cache

* perf(plugins/ui-schema-storage): cache schema

* refactor(plugins/ui-schema-storage): handle schema cache in repository level, not in action

* fix(plugins/ui-schema-storage): jsonSchema use s_ + x-uid and properties use p_ + x-uid cache

prevent jsonSchema and properties cache override each other

* test(plugins/ui-schema-storage): add ui_schema repository with cache test

* build(create-nocobase-app): remove create-nocobase cli's cache-store-package option

* test(plugins/ui-schema-storage): add ui_schema repository with cache test with readFromCache false

* fix(plugins/ui-schema-storage): repository insertAdjacent and patch method clear cache fix

Co-authored-by: chenos <chenlinxh@gmail.com>

* feat: add filter action to collection table (#953)

* feat: 数据表支持筛选

* feat: 数据表支持筛选

* feat: 数据表支持筛选

* feat: 数据表支持筛选

* fix: format code

Co-authored-by: chenos <chenlinxh@gmail.com>

* fix: default value of VERDACCIO_URL

* feat: add Dockerfile.official

* refactor(plugin-file-manager): move client code into plugin folder and enable path config (#913)

* Turkish language (#939)

* Türkçe dil dosyası eklendi. Turkish language added

* Türkçe dil düzenlemesi. Turkish language edit.

* fix: cannot find name 'antdTrTR'

* chore(versions): 😊 publish v0.7.6-alpha.2 (#954)

* Turkish readme (#955)

* Türkçe dil dosyası eklendi. Turkish language added

* Türkçe Açıklama sayfası. Turkish readme file added

* Turkish language link added

* turkish language link added

* fix: update readme

* fix(plugin-workflow): fix constant schedule trigger time (#956)

* feat: push ali docker registry (#957)

* feat: push ali docker registry

* fix: add VERDACCIO_URL

* fix: env

* fix: add VERDACCIO_URL to env

* fix: update VERDACCIO_URL

* fix: arg VERDACCIO_URL

* fix: update Dockerfile.official

* fix: build-args

* fix: network: host

* fix: ci

* fix: verdaccio:10104

* Update branch-docker-ci.yml

* Update branch-docker-ci.yml

* Update branch-docker-ci.yml

* Update branch-docker-ci.yml

* Update branch-docker-ci.yml

* Update branch-docker-ci.yml

* Update branch-docker-ci.yml

* feat: push acr

Co-authored-by: ChengLei Shao <chareice@live.com>

* feat: api service

* fix: api services

* feat(file-manager): support tencent cos (#958)

* feat: support tencent cos

* feat: normalize

* chore: update env example

* test: add cos

* Revert "feat: normalize"

This reverts commit 371880013c20b4cb4174ffaa15e0157acc5306c3.

* feat: normalize

* feat: tengxun to tencent

* chore: fix missing deps

* fix: locale

* fix: locale cos

* fix(database/formula-field): when formula's field caculate result is 0 it alse will be save (#962)

* fix: lerna ERR! EUNCOMMIT  M yarn.lock

* fix: driver-opts: network=host

* fix(ci): add verdaccio service

* chore(versions): 😊 publish v0.7.7-alpha.1 (#963)

* fix(plugin-workflow): fix workflow update action (#964)

* fix(ci): yarn config set network-timeout 600000

* fix: empty logic operator filter (#961)

* chore: fix incorrect deps (#970)

* feat: support show lunar day (#972)

* feat: support show lunar day

* fix: update yarn.lock

* fix: types error

Co-authored-by: Dunqing <dengqing0821@gmail.com>

* fix(ci): remove platforms

* chore: improve ci (#976)

* test: ci

* fix: ci error

* fix: on pull_request

* fix: paths

* fix: paths 'packages/**'

* feat: support show lunar day in week and day (#977)

Co-authored-by: qing <qing@imideMacBook-Air.local>

* fix(client/formula): set cursor focus on input (#959)

* fix(client/formula): set cursor focus on input

* fix(client/formula): when formula field name has contains the other one will case FormulaError

refactor partial implementation

* fix(client/table-selector-provider): make data range config effective (#960)

* fix(client/table-selector-provider): make data range config effective

* fix(client/table-selector): fix when save data range config filter lose

* fix(client/upload): fix upload mutiple files always uploading status (#974)

* Update README.md

* Update README.zh-CN.md

* Update README.tr-TR.md

* feat(calendar): support for add/remove repeats events (#988)

* feat(calendar): support for adding repeats

* feat: support delete events

* fix: has many same x-action

* feat: update better logic

* fix: exclude is not an array

* fix: handle parse cron error

* feat: support every_week, every_month, every_year

* fix(calendar): events cannot support moment (#1017)

* fix(calendar): events cannot support moment

* fix: avoid manipulating raw values

* feat: add excel function field (#906)

* feat: add excel function field

* fix: translations

* fix: documentation

* chore: remove console log

* chore: moved generation of parser to helper function; feat: added GET_DEEP function

* chore: improve tokenisation for when there are similar names;

* chore: move helper to utils

* fix: import from @nocobase/utils/client

* feat: integration as a plugin

* fix: remove excel function interface

* fix: locale

* fix: error

* refactor: formula plugin

* fix: improve code

* feat: update docs

* fix: error

* fix: update deps

* fix: remove formula

* fix:  bigint & test

* fix: result

* fix: double data type

* fix: maximum call stack size exceeded

* fix: improve code

* fix: improve code

* feat(cli): quickstart

* Revert "feat(cli): quickstart"

This reverts commit 99ad54afb6785c9180c46dd3e9e01e25da368218.

* fix: precision

* fix: migration

Co-authored-by: Michael DeGiovanni <m.degiovanni@gmail.com>
This commit is contained in:
chenos 2023-01-10 09:48:46 +08:00 committed by GitHub
parent 3091d25b5e
commit 452ecb5fc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1249 additions and 274 deletions

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-excel-formula-field/client';

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-math-formula-field/client';

View File

@ -6,7 +6,6 @@ export * from './createdAt';
export * from './createdBy';
export * from './datetime';
export * from './email';
export * from './formula';
export * from './icon';
export * from './id';
export * from './input';
@ -31,3 +30,4 @@ export * from './textarea';
export * from './time';
export * from './updatedAt';
export * from './updatedBy';

View File

@ -0,0 +1,220 @@
import { css } from '@emotion/css';
import { Field, onFormSubmitValidateStart } from '@formily/core';
import { useField, useFormEffects } from '@formily/react';
import { Dropdown, Menu } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
function pasteHtml(html, selectPastedContent = false) {
var sel, range;
if (window.getSelection) {
// IE9 and non-IE
sel = window.getSelection();
if (sel.getRangeAt && sel.rangeCount) {
range = sel.getRangeAt(0);
range.deleteContents();
// Range.createContextualFragment() would be useful here but is
// only relatively recently standardized and is not supported in
// some browsers (IE9, for one)
var el = document.createElement('div');
el.innerHTML = html;
var frag = document.createDocumentFragment(),
node,
lastNode;
while ((node = el.firstChild)) {
lastNode = frag.appendChild(node);
}
var firstNode = frag.firstChild;
range.insertNode(frag);
// Preserve the selection
if (lastNode) {
range = range.cloneRange();
range.setStartAfter(lastNode);
if (selectPastedContent) {
range.setStartBefore(firstNode);
} else {
range.collapse(true);
}
sel.removeAllRanges();
sel.addRange(range);
}
}
}
}
const getValue = (el) => {
const values: any[] = [];
for (const node of el.childNodes) {
if (node.nodeName === 'SPAN') {
values.push(`{{${node['dataset']['key']}}}`);
} else {
values.push(node.textContent?.trim?.());
}
}
const text = values.join(' ')?.replace(/\s+/g, ' ').trim();
return ` ${text} `;
};
const renderExp = (exp: string, scope = {}) => {
return exp.replace(/{{([^}]+)}}/g, (_, i) => {
return scope[i.trim()] || '';
});
};
export const Expression = (props) => {
const { evaluate, value, supports, useCurrentFields } = props;
const field = useField<Field>();
const { t } = useTranslation();
const fields = useCurrentFields();
const inputRef = useRef<any>();
const [changed, setChanged] = useState(false);
const onChange = (value) => {
setChanged(true);
props.onChange(value);
};
const numColumns = new Map<string, string>();
const scope = {};
fields
.filter((field) => supports.includes(field.interface))
.forEach((field) => {
numColumns.set(field.name, field.uiSchema.title);
scope[field.name] = 1;
});
const keys = Array.from(numColumns.keys());
const [html, setHtml] = useState(() => {
const scope = {};
for (const key of keys) {
const val = numColumns.get(key);
scope[
key
] = ` <span class="ant-tag" style="margin: 0 3px;" contentEditable="false" data-key="${key}">${val}</span> `;
}
return renderExp(value || '', scope);
});
useEffect(() => {
if (changed) {
return;
}
const scope = {};
for (const key of keys) {
const val = numColumns.get(key);
scope[
key
] = ` <span class="ant-tag" style="margin: 0 3px;" contentEditable="false" data-key="${key}">${val}</span> `;
}
const val = renderExp(value || '', scope);
setHtml(val);
}, [value]);
const menu = (
<Menu>
{keys.length > 0 ? (
keys.map((key) => (
<Menu.Item disabled key={key}>
<button
onClick={async (args) => {
(inputRef.current as any).focus();
const val = numColumns.get(key);
pasteHtml(
` <span class="ant-tag" style="margin: 0 3px;" contentEditable="false" data-key="${key}">${val}</span> `,
);
const text = getValue(inputRef.current);
onChange(text);
console.log('onChange', text);
}}
>
{numColumns.get(key)}
</button>
</Menu.Item>
))
) : (
<Menu.Item disabled key={0}>
{t('No available fields')}
</Menu.Item>
)}
</Menu>
);
useFormEffects(() => {
onFormSubmitValidateStart(() => {
try {
evaluate(field.value, scope);
field.feedbacks = [];
} catch (e) {
console.error(field.value, scope, (e as Error).message);
field.setFeedback({
type: 'error',
code: 'FormulaError',
messages: [t('Formula error.')],
});
}
});
});
return (
<Dropdown
trigger={['click']}
overlay={menu}
overlayClassName={css`
.ant-dropdown-menu-item {
padding: 0;
}
button {
cursor: pointer;
padding: 5px 12px;
text-align: left;
color: rgba(0, 0, 0, 0.85);
width: 100%;
line-height: inherit;
height: auto;
border: 0px;
background-color: transparent;
&:hover {
background-color: #f5f5f5;
}
}
`}
>
<div
onKeyDown={(e) => {
const text = getValue(e.currentTarget);
if (e.key === 'Backspace') {
if (text && keys.map((k) => `{{${k}}}`).includes(text)) {
inputRef.current.innerHTML = ' ';
}
}
}}
onKeyUp={(e) => {
const text = getValue(e.currentTarget);
if (e.key === 'Backspace') {
// pasteHtml(' ');
}
onChange(text);
}}
// onClick={(e) => {
// const text = getValue(e.currentTarget);
// onChange(text);
// console.log('onChange', text);
// }}
onBlur={(e) => {
const text = getValue(e.currentTarget);
onChange(text);
}}
onInput={(e) => {
const text = getValue(e.currentTarget);
onChange(text);
}}
className={'ant-input'}
style={{ display: 'block' }}
ref={inputRef as any}
contentEditable
dangerouslySetInnerHTML={{ __html: html }}
/>
</Dropdown>
);
};
export default Expression;

View File

@ -0,0 +1,53 @@
import { onFormValuesChange } from '@formily/core';
import { useField, useFieldSchema, useForm, useFormEffects } from '@formily/react';
import { toFixedByStep } from '@nocobase/utils/client';
import cloneDeep from 'lodash/cloneDeep';
import * as math from 'mathjs';
import { isNumber } from 'mathjs';
import React, { useState } from 'react';
import { useCollection } from '../collection-manager';
const ReadPretty = (props) => {
if (props?.options?.dataType !== 'string') {
return <div>{toFixedByStep(props.value, props.step)}</div>;
}
return <div>{props.value}</div>;
};
const Input = (props) => {
const { evaluate, options } = props;
const { dataType, expression } = options;
const form = useForm();
const val = () => {
const scope = cloneDeep(form.values);
try {
let result = evaluate(expression, scope);
result = isNumber(result) && Number.isFinite(result) ? math.round(result, 9) : result;
return result;
} catch (error) {
return null;
}
};
const [value, setVal] = useState(() => {
return val();
});
useFormEffects(() => {
onFormValuesChange(() => {
setVal(val());
});
});
if (dataType !== 'string') {
return <div>{toFixedByStep(value, props.step)}</div>;
}
return <div>{value}</div>;
};
export const Result = (props: any) => {
const field = useField();
const { getField } = useCollection();
const fieldSchema = useFieldSchema();
const options = getField(fieldSchema.name as string);
return field.readPretty ? <ReadPretty {...props} options={options} /> : <Input {...props} options={options} />;
};
export default Result;

View File

@ -0,0 +1,51 @@
/**
* title: Formula
*/
import { connect } from '@formily/react';
import { Formula, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const Expression = connect(Formula.Expression);
const schema = {
type: 'object',
properties: {
input: {
type: 'string',
default: '{{f1 }} + {{ f2}}',
'x-component': 'Expression',
'x-component-props': {
evaluate: (exp, scope) => {
return 1;
},
supports: ['number'],
useCurrentFields: () => {
return [
{
name: 'f1',
interface: 'number',
uiSchema: {
title: 'F1',
},
},
{
name: 'f2',
interface: 'number',
uiSchema: {
title: 'F2',
},
},
];
},
},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Expression }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,10 @@
---
nav:
path: /client
group:
path: /client
---
# Formula
<code src="./demos/demo2.tsx"/>

View File

@ -0,0 +1,9 @@
import Expression from './Expression';
import Result from './Result';
export const Formula = () => null;
Formula.Result = Result;
Formula.Expression = Expression;
export default Formula;

View File

@ -11,6 +11,7 @@ export * from './board';
export * from './china-region';
export * from './collection-manager';
export * from './document-title';
export * from './formula';
export * from './i18n';
export * from './icon';
export * from './plugin-manager';

View File

@ -1,36 +0,0 @@
import { onFormValuesChange } from '@formily/core';
import { connect, mapReadPretty, useFieldSchema, useFormEffects } from '@formily/react';
import { InputNumber } from 'antd';
import _ from 'lodash';
import * as math from 'mathjs';
import React from 'react';
import { useCollection } from '../../../collection-manager/hooks';
import { ReadPretty } from '../input-number/ReadPretty';
const AntdCompute = (props) => {
const { onChange, ...others } = props;
const { getField } = useCollection();
const fieldSchema = useFieldSchema();
const options = getField(fieldSchema.name);
const { expression } = options;
useFormEffects(() => {
onFormValuesChange((form) => {
const scope = _.cloneDeep(form.values);
let result;
try {
result = math.evaluate(expression, scope);
result = Number.isFinite(result) ? math.round(result, 9) : null;
} catch{}
if (onChange) {
onChange(result);
}
});
});
return <InputNumber {...others} readOnly stringMode={true} />;
};
export const Compute = connect(AntdCompute, mapReadPretty(ReadPretty));
export default Compute;

View File

@ -1,120 +0,0 @@
import { Field, onFormSubmitValidateStart } from '@formily/core';
import { connect, mapProps, useField, useFormEffects } from '@formily/react';
import { Menu, Dropdown } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import ContentEditable from 'react-contenteditable';
import { useTranslation } from 'react-i18next';
import * as math from 'mathjs';
const AntdFormulaInput = (props) => {
const { value, onChange, supports, useCurrentFields } = props;
const field = useField<Field>();
const { t } = useTranslation();
const fields = useCurrentFields();
const inputRef = useRef();
const [dropdownVisible, setDropdownVisible] = useState(false);
const [html, setHtml] = useState(null);
const numColumns = new Map<string, string>();
const scope = {};
fields
.filter((field) => supports.includes(field.interface))
.forEach((field) => {
numColumns.set(field.name, field.uiSchema.title);
scope[field.name] = 1;
});
const keys = Array.from(numColumns.keys());
useEffect(() => {
if (value) {
let newHtml = value;
numColumns.forEach((value, key) => {
newHtml = newHtml.replaceAll(
key,
`<span contentEditable="false" ><input disabled="disabled" style="width:${
18 * value.length
}px;max-width: 120px" value="${value}"/><span hidden>${key}</span></span>`,
);
});
newHtml = `${newHtml}<span style="padding-left: 5px"></span>`; // set extra span for cursor focus on last position
setHtml(newHtml);
} else {
setHtml('');
}
}, [value]);
const items = keys.map((key) => {
return { label: numColumns.get(key), key };
});
const menu = (
<Menu
onClick={async (args) => {
const replaceFormula = field.value.replace('@', args.key);
if (onChange && replaceFormula != field.value) {
onChange(replaceFormula);
}
setDropdownVisible(false);
(inputRef.current as any).focus();
}}
items={items}
/>
);
const handleChange = (e) => {
if (onChange) {
if (e.currentTarget.textContent == '') {
onChange(null);
} else {
onChange(e.currentTarget.textContent);
}
}
};
const handleKeyDown = (e) => {
const { key } = e;
switch (key) {
case 'Enter':
e.preventDefault();
break;
case '@':
case 'Process':
setDropdownVisible(true);
break;
default:
setDropdownVisible(false);
break;
}
};
useFormEffects(() => {
onFormSubmitValidateStart(() => {
try {
math.evaluate(field.value, scope);
field.feedbacks = [];
} catch (e) {
console.error(field.value, scope, (e as Error).message);
field.setFeedback({
type: 'error',
code: 'FormulaError',
messages: [t('Formula error.')],
});
}
});
});
return (
<Dropdown overlay={menu} visible={dropdownVisible}>
<ContentEditable
innerRef={inputRef}
className="ant-input"
onChange={handleChange}
onKeyDown={handleKeyDown}
html={html || ''}
/>
</Dropdown>
);
};
export const FormulaInput = connect(AntdFormulaInput, mapProps({}));
export default FormulaInput;

View File

@ -1,6 +0,0 @@
import { Compute } from './Compute';
import { FormulaInput } from './FormulaInput';
export const Formula: any = () => null;
Formula.Expression = FormulaInput;
Formula.Result = Compute;

View File

@ -4,14 +4,13 @@ export * from './calendar';
export * from './card-item';
export * from './cascader';
export * from './checkbox';
export * from './cron';
export * from './color-select';
export * from './cron';
export * from './date-picker';
export * from './filter';
export * from './form';
export * from './form-item';
export * from './form-v2';
export * from './formula-input';
export * from './g2plot';
export * from './grid';
export * from './icon-picker';

View File

@ -1,69 +0,0 @@
import { mockDatabase } from '..';
import { Database } from '../../database';
import { FormulaField } from '../../fields';
describe('formula field', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
});
afterEach(async () => {
await db.close();
});
it('add formula field with old table, already has data.', async () => {
const Test = db.collection({
name: 'tests',
fields: [
{ type: 'float', name: 'price' },
{ type: 'float', name: 'count' },
],
});
await db.sync();
const test = await Test.model.create<any>({
price: '1.2',
count: '2',
});
const expression = 'price*count';
const field = Test.addField('sum', { type: 'formula', expression });
await field.sync({});
const updatedTest = await Test.model.findByPk(test.id);
const sum = updatedTest.get('sum');
const sumField = Test.getField<FormulaField>('sum');
expect(sum).toEqual(sumField.caculate(expression, updatedTest.toJSON()));
});
it('auto set formula field with create or update data', async () => {
const expression = 'price*count';
const Test = db.collection({
name: 'tests',
fields: [
{ type: 'float', name: 'price' },
{ type: 'float', name: 'count' },
{ name: 'sum', type: 'formula', expression },
],
});
await db.sync();
const test = await Test.model.create<any>({
price: '1.2',
count: '2',
});
const sumField = Test.getField<FormulaField>('sum');
expect(test.get('sum')).toEqual(sumField.caculate(expression, test.toJSON()));
test.set('count', '6');
await test.save();
expect(test.get('sum')).toEqual(sumField.caculate(expression, test.toJSON()));
});
});

View File

@ -6,12 +6,12 @@ import {
ModelIndexesOptions,
QueryInterfaceOptions,
SyncOptions,
Transactionable,
Transactionable
} from 'sequelize';
import { Collection } from '../collection';
import { Database } from '../database';
import { ModelEventTypes } from '../types';
import { InheritedCollection } from '../inherited-collection';
import { ModelEventTypes } from '../types';
export interface FieldContext {
database: Database;

View File

@ -13,10 +13,11 @@ import {
DoubleFieldOptions,
FloatFieldOptions,
IntegerFieldOptions,
RealFieldOptions,
RealFieldOptions
} from './number-field';
import { PasswordFieldOptions } from './password-field';
import { RadioFieldOptions } from './radio-field';
import { SetFieldOptions } from './set-field';
import { SortFieldOptions } from './sort-field';
import { StringFieldOptions } from './string-field';
import { TextFieldOptions } from './text-field';
@ -24,11 +25,8 @@ import { TimeFieldOptions } from './time-field';
import { UidFieldOptions } from './uid-field';
import { UUIDFieldOptions } from './uuid-field';
import { VirtualFieldOptions } from './virtual-field';
import { FormulaFieldOptions } from './formula-field';
import { SetFieldOptions } from './set-field';
export * from './array-field';
export * from './set-field';
export * from './belongs-to-field';
export * from './belongs-to-many-field';
export * from './boolean-field';
@ -42,6 +40,7 @@ export * from './number-field';
export * from './password-field';
export * from './radio-field';
export * from './relation-field';
export * from './set-field';
export * from './sort-field';
export * from './string-field';
export * from './text-field';
@ -49,7 +48,6 @@ export * from './time-field';
export * from './uid-field';
export * from './uuid-field';
export * from './virtual-field';
export * from './formula-field';
export type FieldOptions =
| BaseFieldOptions
@ -66,7 +64,6 @@ export type FieldOptions =
| SortFieldOptions
| TextFieldOptions
| VirtualFieldOptions
| FormulaFieldOptions
| ArrayFieldOptions
| SetFieldOptions
| TimeFieldOptions

View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/client"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,12 @@
{
"name": "@nocobase/plugin-excel-formula-field",
"version": "0.8.1-alpha.4",
"main": "lib/server/index.js",
"dependencies": {
"@formulajs/formulajs": "^3.1.7"
},
"devDependencies": {
"@nocobase/server": "0.8.1-alpha.4",
"@nocobase/test": "0.8.1-alpha.4"
}
}

View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/server"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,20 @@
import { connect, observer } from '@formily/react';
import { Formula } from '@nocobase/client';
import React from 'react';
import { evaluate } from '../../utils/evaluate';
export const ExcelFormula: any = () => null;
ExcelFormula.Result = observer((props) =>
React.createElement(Formula.Result, {
...props,
evaluate,
}),
);
ExcelFormula.Expression = connect((props) => {
return React.createElement(Formula.Expression, {
...props,
evaluate,
});
});

View File

@ -0,0 +1,82 @@
import { IField, interfacesProperties } from '@nocobase/client';
const { defaultProps, operators } = interfacesProperties;
export const excelFormula: IField = {
name: 'excelFormula',
type: 'object',
group: 'advanced',
order: 2,
title: '{{t("Excel formula")}}',
description: '{{t("Compute a value based on the other fields using excel formula functions")}}',
sortable: true,
default: {
type: 'excelFormula',
// name,
uiSchema: {
type: 'string',
// title,
'x-disabled': true,
'x-component': 'ExcelFormula.Result',
'x-component-props': {
stringMode: true,
step: '1',
},
},
},
properties: {
...defaultProps,
dataType: {
type: 'string',
title: '{{t("Data type")}}',
'x-component': 'Select',
'x-decorator': 'FormItem',
default: 'number',
'x-disabled': '{{ !createOnly }}',
"x-reactions": [
{
target: 'uiSchema.x-component-props.step',
fulfill: {
state: {
display: '{{$self.value !== "string" ? "visible" : "none"}}',
},
},
}
],
enum: [
{ value: 'string', label: '{{t("String")}}' },
{ value: 'number', label: '{{t("Number")}}' },
],
},
'uiSchema.x-component-props.step': {
type: 'string',
title: '{{t("Precision")}}',
'x-component': 'Select',
'x-decorator': 'FormItem',
required: true,
default: '0',
enum: [
{ value: '0', label: '1' },
{ value: '0.1', label: '1.0' },
{ value: '0.01', label: '1.00' },
{ value: '0.001', label: '1.000' },
{ value: '0.0001', label: '1.0000' },
{ value: '0.00001', label: '1.00000' },
],
},
expression: {
type: 'string',
title: '{{t("Expression")}}',
required: true,
description: '{{excelExpressionDescription}}',
'x-component': 'ExcelFormula.Expression',
'x-decorator': 'FormItem',
'x-component-props': {
supports: ['number', 'percent', 'integer', 'string'],
useCurrentFields: '{{ useCurrentFields }}',
},
},
},
filterable: {
operators: operators.string,
},
};

View File

@ -0,0 +1,34 @@
import { CollectionManagerContext, registerField, SchemaComponentOptions } from '@nocobase/client';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { excelFormula } from './excel-formula';
import { ExcelFormula } from './ExcelFormula';
registerField(excelFormula.group, 'excelFormula', excelFormula);
export default React.memo((props) => {
const ctx = useContext(CollectionManagerContext);
const { t } = useTranslation();
return (
<SchemaComponentOptions
scope={{
excelExpressionDescription: (
<div>
{t('Syntax see', { ns: 'math-formula-field' })}{' '}
<a target={'_blank'} href={'https://formulajs.info/functions/'}>
formula.js
</a>
</div>
),
}}
components={{
ExcelFormula,
}}
>
<CollectionManagerContext.Provider value={{ ...ctx, interfaces: { ...ctx.interfaces, excelFormula } }}>
{props.children}
</CollectionManagerContext.Provider>
</SchemaComponentOptions>
);
});

View File

@ -0,0 +1 @@
export { default } from './server';

View File

@ -0,0 +1,108 @@
import { BaseFieldOptions, Field } from '@nocobase/database';
import { DataTypes } from 'sequelize';
import { evaluate } from '../utils/evaluate';
export class ExcelFormulaField extends Field {
get dataType() {
const { dataType } = this.options;
return dataType === 'string' ? DataTypes.STRING : DataTypes.DOUBLE;
}
calculate(expression, scope) {
try {
return evaluate(expression, scope);
} catch {}
return null;
}
initFieldData = async ({ transaction }) => {
const { expression, name } = this.options;
const records = await this.collection.repository.find({
order: [this.collection.model.primaryKeyAttribute],
transaction,
});
for (const record of records) {
const scope = record.toJSON();
const result = this.calculate(expression, scope);
if (result) {
await record.update(
{
[name]: result,
},
{
transaction,
silent: true,
hooks: false,
},
);
}
}
};
calculateField = async (instance) => {
const { expression, name } = this.options;
const scope = instance.toJSON();
let result = this.calculate(expression, scope);
if (result) {
instance.set(name, result);
}
};
updateFieldData = async (instance, { transaction }) => {
if (this.collection.name === instance.collectionName && instance.name === this.options.name) {
this.options = Object.assign(this.options, instance.options);
const { name, expression } = this.options;
const records = await this.collection.repository.find({
order: [this.collection.model.primaryKeyAttribute],
transaction,
});
for (const record of records) {
const scope = record.toJSON();
const result = this.calculate(expression, scope);
await record.update(
{
[name]: result,
},
{
transaction,
silent: true,
hooks: false,
},
);
}
}
};
bind() {
super.bind();
this.on('afterSync', this.initFieldData);
// TODO: should not depends on fields table (which is defined by other plugin)
this.database.on('fields.afterUpdate', this.updateFieldData);
this.on('beforeSave', this.calculateField);
}
unbind() {
super.unbind();
this.off('beforeSave', this.calculateField);
// TODO: should not depends on fields table
this.database.off('fields.afterUpdate', this.updateFieldData);
this.off('afterSync', this.initFieldData);
}
toSequelize() {
const opts = super.toSequelize();
delete opts.dataType;
return opts;
}
}
export interface ExcelFormulaFieldOptions extends BaseFieldOptions {
type: 'excelFormula';
dataType?: 'number' | 'string';
expression: string;
}

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,24 @@
import { InstallOptions, Plugin } from '@nocobase/server';
import { ExcelFormulaField } from './excel-formula-field';
export class ExcelFormulaFieldPlugin extends Plugin {
afterAdd() {}
beforeLoad() {
this.db.registerFieldTypes({
excelFormula: ExcelFormulaField,
});
}
async load() {}
async install(options?: InstallOptions) {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default ExcelFormulaFieldPlugin;

View File

@ -0,0 +1,9 @@
import * as formulajs from '@formulajs/formulajs';
export function evaluate(exp: string, scope = {}) {
const expression = exp.replace(/{{([^}]+)}}/g, (match, i) => {
return scope[i.trim()] || '';
});
const fn = new Function(...Object.keys(formulajs), ...Object.keys(scope), `return ${expression}`);
return fn(...Object.values(formulajs), ...Object.values(scope));
}

View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/client"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,12 @@
{
"name": "@nocobase/plugin-math-formula-field",
"version": "0.8.1-alpha.4",
"main": "lib/server/index.js",
"dependencies": {
"mathjs": "^10.6.0"
},
"devDependencies": {
"@nocobase/server": "0.8.1-alpha.4",
"@nocobase/test": "0.8.1-alpha.4"
}
}

View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/server"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,20 @@
import { connect, observer } from '@formily/react';
import { Formula } from '@nocobase/client';
import React from 'react';
import { evaluate } from '../../utils/evaluate';
export const MathFormula: any = () => null;
MathFormula.Result = observer((props) =>
React.createElement(Formula.Result, {
...props,
evaluate,
}),
);
MathFormula.Expression = connect((props) => {
return React.createElement(Formula.Expression, {
...props,
evaluate,
});
});

View File

@ -0,0 +1,31 @@
import { CollectionManagerContext, registerField, SchemaComponentOptions } from '@nocobase/client';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { mathFormula } from './math-formula';
import { MathFormula } from './MathFormula';
registerField(mathFormula.group, 'mathFormula', mathFormula);
export default React.memo((props) => {
const ctx = useContext(CollectionManagerContext);
const { t } = useTranslation();
return (
<SchemaComponentOptions
scope={{
mathExpressionDescription: (
<div>
{t('Syntax see', { ns: 'math-formula-field' })}{' '}
<a target={'_blank'} href={'https://mathjs.org/docs/expressions/syntax.html'}>
math.js
</a>
</div>
),
}}
components={{ MathFormula }}
>
<CollectionManagerContext.Provider value={{ ...ctx, interfaces: { ...ctx.interfaces, mathFormula } }}>
{props.children}
</CollectionManagerContext.Provider>
</SchemaComponentOptions>
);
});

View File

@ -1,22 +1,22 @@
import { defaultProps, operators } from './properties';
import { IField } from './types';
import { IField, interfacesProperties } from '@nocobase/client';
const { defaultProps, operators } = interfacesProperties;
export const formula: IField = {
name: 'formula',
export const mathFormula: IField = {
name: 'mathFormula',
type: 'object',
group: 'advanced',
order: 1,
title: '{{t("Formula")}}',
description: '{{t("Formula description")}}',
title: '{{t("Math formula")}}',
description: '{{t("Compute a value based on the other fields using mathjs")}}',
sortable: true,
default: {
type: 'formula',
type: 'mathFormula',
// name,
uiSchema: {
type: 'number',
// title,
"x-disabled": true,
'x-component': 'Formula.Result',
'x-disabled': true,
'x-component': 'MathFormula.Result',
'x-component-props': {
stringMode: true,
step: '1',
@ -25,16 +25,16 @@ export const formula: IField = {
},
properties: {
...defaultProps,
'expression': {
expression: {
type: 'string',
title: '{{t("Expression")}}',
required: true,
description: '{{t("Input +, -, *, /, ( ) to calculate, input @ to open field variables.")}}',
'x-component': 'Formula.Expression',
description: '{{mathExpressionDescription}}',
'x-component': 'MathFormula.Expression',
'x-decorator': 'FormItem',
'x-component-props': {
'supports': ['number', 'percent', 'integer'],
'useCurrentFields': '{{ useCurrentFields }}'
supports: ['number', 'percent', 'integer'],
useCurrentFields: '{{ useCurrentFields }}',
},
},
'uiSchema.x-component-props.step': {

View File

@ -0,0 +1 @@
export { default } from './server';

View File

@ -0,0 +1,90 @@
import { Database, mockDatabase } from '@nocobase/database';
import { MathFormulaField } from '../math-formula-field';
describe('formula field', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
db.registerFieldTypes({
mathFormula: MathFormulaField,
});
});
afterEach(async () => {
await db.close();
});
it('auto set formula field with create or update data', async () => {
const expression = 'price*count';
const Test = db.collection({
name: 'tests',
fields: [
{ type: 'float', name: 'price' },
{ type: 'float', name: 'count' },
{ name: 'sum', type: 'mathFormula', expression },
],
});
await db.sync();
const test = await Test.model.create<any>({
price: '1.2',
count: '2',
});
const sumField = Test.getField('sum');
expect(test.get('sum')).toEqual(2.4);
test.set('count', '6');
await test.save();
expect(test.get('sum')).toEqual(7.2);
});
it('auto set formula field with create or update data', async () => {
const expression = '{{price}}*{{count}}';
const Test = db.collection({
name: 'tests',
fields: [
{ type: 'float', name: 'price' },
{ type: 'float', name: 'count' },
{ name: 'sum', type: 'mathFormula', expression },
],
});
await db.sync();
const test = await Test.model.create<any>({
price: '1.2',
count: '2',
});
const sumField = Test.getField('sum');
expect(test.get('sum')).toEqual(2.4);
test.set('count', '6');
await test.save();
expect(test.get('sum')).toEqual(7.2);
});
it('1.22+2=3.22', async () => {
const expression = '{{a}}+{{b}}';
const Test = db.collection({
name: 'tests',
fields: [
{ type: 'float', name: 'a' },
{ type: 'float', name: 'b' },
{ name: 'sum', type: 'mathFormula', expression },
],
});
await db.sync();
const test = await Test.model.create<any>({
a: '2',
b: '1.22',
});
expect(test.get('sum')).toEqual(3.22);
});
});

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -1,16 +1,17 @@
import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field } from './field';
import { BaseFieldOptions, Field } from '@nocobase/database';
import * as math from 'mathjs';
import { DataTypes } from 'sequelize';
import { evaluate } from '../utils/evaluate';
export class FormulaField extends Field {
export class MathFormulaField extends Field {
get dataType() {
return DataTypes.FLOAT;
return DataTypes.DOUBLE;
}
caculate(expression, scope) {
let result = null;
calculate(expression, scope) {
let result: any = null;
try {
result = math.evaluate(expression, scope);
result = evaluate(expression, scope);
result = math.round(result, 9);
} catch {}
return result;
@ -26,7 +27,7 @@ export class FormulaField extends Field {
for (const record of records) {
const scope = record.toJSON();
const result = this.caculate(expression, scope);
const result = this.calculate(expression, scope);
if (result) {
await record.update(
{
@ -42,12 +43,12 @@ export class FormulaField extends Field {
}
};
caculateField = async (instance) => {
calculateField = async (instance) => {
const { expression, name } = this.options;
const scope = instance.toJSON();
let result;
try {
result = math.evaluate(expression, scope);
result = evaluate(expression, scope);
result = math.round(result, 9);
} catch {}
if (result === 0 || result) {
@ -67,7 +68,7 @@ export class FormulaField extends Field {
for (const record of records) {
const scope = record.toJSON();
const result = this.caculate(expression, scope);
const result = this.calculate(expression, scope);
await record.update(
{
[name]: result,
@ -87,20 +88,19 @@ export class FormulaField extends Field {
this.on('afterSync', this.initFieldData);
// TODO: should not depends on fields table (which is defined by other plugin)
this.database.on('fields.afterUpdate', this.updateFieldData);
this.on('beforeSave', this.caculateField);
this.on('beforeSave', this.calculateField);
}
unbind() {
super.unbind();
this.off('beforeSave', this.caculateField);
this.off('beforeSave', this.calculateField);
// TODO: should not depends on fields table
this.database.off('fields.afterUpdate', this.updateFieldData);
this.off('afterSync', this.initFieldData);
}
}
export interface FormulaFieldOptions extends BaseColumnFieldOptions {
type: 'formula';
export interface MathFormulaFieldOptions extends BaseFieldOptions {
type: 'mathFormula';
expression: string;
}

View File

@ -0,0 +1,24 @@
import { Migration } from '@nocobase/server';
export default class extends Migration {
async up() {
const result = await this.app.version.satisfies('<=0.8.1-alpha.4');
if (!result) {
return;
}
const Field = this.context.db.getRepository('fields');
const fields = await Field.find();
await this.context.db.sequelize.transaction(async (transaction) => {
for (const field of fields) {
if (field.get('type') === 'formula') {
field.set('type', 'mathFormula');
field.set('interface', 'mathFormula');
await field.save({ transaction });
const schema = await field.getUiSchema({ transaction });
schema.set('x-component', 'MathFormula.Result');
await schema.save({ transaction });
}
}
});
}
}

View File

@ -0,0 +1,34 @@
import { InstallOptions, Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { MathFormulaField } from './math-formula-field';
export class MathFormulaFieldPlugin extends Plugin {
afterAdd() {}
beforeLoad() {
this.db.registerFieldTypes({
formula: MathFormulaField,
mathFormula: MathFormulaField,
});
this.db.addMigrations({
namespace: this.name,
directory: resolve(__dirname, './migrations'),
context: {
plugin: this,
},
});
}
async load() {}
async install(options?: InstallOptions) {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default MathFormulaFieldPlugin;

View File

@ -0,0 +1,8 @@
import * as math from 'mathjs';
export function evaluate(exp: string, scope = {}) {
const expression = exp.replace(/{{([^}]+)}}/g, (_, i) => {
return scope[i.trim()] || '';
});
return math.evaluate(expression, scope);
}

View File

@ -21,6 +21,8 @@
"@nocobase/plugin-export": "0.8.1-alpha.4",
"@nocobase/plugin-file-manager": "0.8.1-alpha.4",
"@nocobase/plugin-iframe-block": "0.8.1-alpha.4",
"@nocobase/plugin-math-formula-field": "0.8.1-alpha.4",
"@nocobase/plugin-excel-formula-field": "0.8.1-alpha.4",
"@nocobase/plugin-import": "0.8.1-alpha.4",
"@nocobase/plugin-map": "0.8.1-alpha.4",
"@nocobase/plugin-oidc": "0.8.1-alpha.4",

View File

@ -25,6 +25,8 @@ export class PresetNocoBase extends Plugin {
'audit-logs',
'duplicator',
'iframe-block',
'math-formula-field',
'excel-formula-field',
].concat(plugins),
);
}

@ -1 +1 @@
Subproject commit 016bec621e2576f4cda176cf3d42c9eb7e2eb857
Subproject commit 5a5fba41ae1cecaf3b073fcb610e9dc68dad0b85

214
yarn.lock
View File

@ -355,7 +355,7 @@
resolved "https://registry.npmjs.org/@antv/x6-react-shape/-/x6-react-shape-1.6.3.tgz#053660bd555c4356e9719134e3e2a857b51301e2"
integrity sha512-iCsRVkvKCVs7HUUblF/ALg5XxeeEPXDLHuYzRvbvNlCKKLbXLbaz6vJQSgTTrk2i4lrD+7xl4riA76FyWA3k/g==
"@antv/x6@^1.9.0":
"@antv/x6@^1.34.2":
version "1.34.6"
resolved "https://registry.npmjs.org/@antv/x6/-/x6-1.34.6.tgz#b802b09b52c8620acf01734397714ea5e3f715b6"
integrity sha512-cl8ywN7CTNVT94/ZEbq2mnZQTQe6FUgIlGOAjNOPL8sljZTBrpE6OsuGw4BDA2/XllwELfUfvVpgBvbdwRKrxw==
@ -3274,6 +3274,14 @@
dependencies:
"@formily/shared" "2.0.20"
"@formulajs/formulajs@^3.1.7":
version "3.2.0"
resolved "https://registry.npmjs.org/@formulajs/formulajs/-/formulajs-3.2.0.tgz#78425f4d49cee49d7d2169c2b9d0bccf2f92afa6"
integrity sha512-6rkarI3MTpOTWlvjG1AqRvF/M15Dga/Y6qDGOpxI5ekJGDZKpKRiItTAZ9VEZiDKvlZc42qkRy6tIV5FwHPuAw==
dependencies:
bessel "^1.0.2"
jstat "^1.9.6"
"@gar/promisify@^1.0.1":
version "1.1.2"
resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz#30aa825f11d438671d585bd44e7fd564535fc210"
@ -4411,6 +4419,153 @@
call-me-maybe "^1.0.1"
glob-to-regexp "^0.3.0"
"@nocobase/acl@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/acl/-/acl-0.8.0-alpha.9.tgz#7ffab4a367befc74daa681b1d5c2c907126cb280"
integrity sha512-jEgpjO6gGi4GwSyWxlmcwd368/AQgmrdsODDTLhhrJJI9+ovj2mxO664lQgh3FkDGtmduKlBiZ+qcgK+/RWa1w==
dependencies:
"@nocobase/resourcer" "0.8.0-alpha.9"
json-templates "^4.2.0"
"@nocobase/actions@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/actions/-/actions-0.8.0-alpha.9.tgz#3804bbf30f7c4e8aa205b9802ec4f56a58c18c72"
integrity sha512-EvdNjXa+TTMU2Xn+MnA7tSa5rooGFtzsvJsSYbkpODtsVQsmTDRIZYZsOYFk/XcezxjzTTsxxXMSA7sLq6wifQ==
dependencies:
"@nocobase/cache" "0.8.0-alpha.9"
"@nocobase/database" "0.8.0-alpha.9"
"@nocobase/resourcer" "0.8.0-alpha.9"
"@nocobase/cache@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/cache/-/cache-0.8.0-alpha.9.tgz#99f7ca196dacd41b69ed7b7f179fcc1c4c5baa27"
integrity sha512-GCi7OE9nLrXKq/Og6SPtPcjtreUxxAL9x7CwmD2x3Zb7gNTc2GvQ9NWoAlO8KTLlWuzuNcJXegBhxaxV8Lt5ew==
dependencies:
cache-manager "^4.1.0"
"@nocobase/client@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/client/-/client-0.8.0-alpha.9.tgz#03446b4c4160a7ebda9dfd8aa80e2203541eb72c"
integrity sha512-cvh01k8Ujhc9D7kDYQ7QKP5tnLF+wBOqixtisivti9aeklMBaOKj1oLmxKHZlB2IJzKpT+rUntpsxGxUS6co9A==
dependencies:
"@antv/g2plot" "^2.4.18"
"@dnd-kit/core" "^5.0.1"
"@dnd-kit/sortable" "^6.0.0"
"@emotion/css" "^11.7.1"
"@formily/antd" "2.0.20"
"@formily/core" "2.0.20"
"@formily/react" "2.0.20"
"@nocobase/sdk" "0.8.0-alpha.9"
"@nocobase/utils" "0.8.0-alpha.9"
ahooks "^3.0.5"
antd "~4.19.5"
axios "^0.26.1"
classnames "^2.3.1"
cron-parser "^4.6.0"
cronstrue "^2.11.0"
file-saver "^2.0.5"
i18next "^21.6.0"
json-templates "^4.2.0"
marked "^4.0.12"
mathjs "^10.6.0"
react-beautiful-dnd "^13.1.0"
react-big-calendar "^0.38.7"
react-contenteditable "^3.3.6"
react-drag-listview "^0.1.9"
react-helmet "^6.1.0"
react-hotkeys-hook "^3.4.7"
react-i18next "^11.15.1"
react-image-lightbox "^5.1.4"
react-js-cron "^1.4.0"
react-quill "^1.3.5"
react-router-dom "^5.2.0"
react-to-print "^2.14.7"
solarlunar-es "^1.0.9"
use-deep-compare-effect "^1.8.1"
"@nocobase/database@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/database/-/database-0.8.0-alpha.9.tgz#9684b4ddcfc5396df2ef3d4ae9cd2ce59142df81"
integrity sha512-zkLTW2iFg1tlbSTlW40oEmM7O2GxHXmIzErnDR4it0GWoLqjDgzNHP5QjODOxiiB/+/unNFudfGTNCoXxu2ZvA==
dependencies:
"@nocobase/utils" "0.8.0-alpha.9"
async-mutex "^0.3.2"
cron-parser "4.4.0"
deepmerge "^4.2.2"
flat "^5.0.2"
glob "^7.1.6"
mathjs "^10.6.1"
moment "2.x"
semver "^7.3.7"
sequelize "^6.9.0"
umzug "^3.1.1"
"@nocobase/resourcer@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/resourcer/-/resourcer-0.8.0-alpha.9.tgz#5415af5944b31293e5e2da73bf5c999866a90fab"
integrity sha512-6v9bfUex2RfT8C3xX+SUhj8/zECCY6pQA3ce8GV3xd9cu6ku8c7hIiNM3KwgPOD189aND3FxNwxqpECNaoWQ1A==
dependencies:
"@nocobase/utils" "0.8.0-alpha.9"
deepmerge "^4.2.2"
koa-compose "^4.1.0"
lodash "^4.17.21"
path-to-regexp "^6.1.0"
qs "^6.9.4"
"@nocobase/sdk@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/sdk/-/sdk-0.8.0-alpha.9.tgz#5306e7bf4aabf00894a65036c584508d93bda379"
integrity sha512-v+GF4N5ldYwmoqQIukGWzz/eOm2TgxcUZq3uE+YeP/aiDa4XAfkLrpBNsvCS24k3s/U+84FYrvTWRRStJGSzBA==
dependencies:
axios "^0.26.1"
qs "^6.10.1"
"@nocobase/server@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/server/-/server-0.8.0-alpha.9.tgz#bc1955e41b2ac9f6640fdf109516376604d4092c"
integrity sha512-/mHk2Uc8RS/3GcGvnMnFKlbf2aCufGuc+GOr5gEKuyWa50ibD4nZ7LKJx023uEO07rxWkFkhR6+ExuZ+BGRDGQ==
dependencies:
"@hapi/topo" "^6.0.0"
"@koa/cors" "^3.1.0"
"@koa/router" "^9.4.0"
"@nocobase/acl" "0.8.0-alpha.9"
"@nocobase/actions" "0.8.0-alpha.9"
"@nocobase/database" "0.8.0-alpha.9"
"@nocobase/resourcer" "0.8.0-alpha.9"
chalk "^4.1.1"
commander "^9.2.0"
find-package-json "^1.2.0"
i18next "^21.6.0"
koa "^2.13.4"
koa-bodyparser "^4.3.0"
koa-static "^5.0.0"
lodash "^4.17.21"
semver "^7.3.7"
xpipe "^1.0.5"
"@nocobase/test@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/test/-/test-0.8.0-alpha.9.tgz#40aaed50af9b8fd23c0666156c806273c75b0a3f"
integrity sha512-bpzcogPKzfq8mjIsLDy0xlsd0dNAUgmju9kk+RPV5wNZfhvofaA7YduexawbL+dLJQC+iyjmhAhARxl8rVhKuA==
dependencies:
"@nocobase/server" "0.8.0-alpha.9"
"@types/supertest" "^2.0.11"
mockjs "^1.1.0"
mysql2 "^2.3.3"
pg "^8.7.3"
pg-hstore "^2.3.4"
sqlite3 "^5.0.8"
supertest "^6.1.6"
"@nocobase/utils@0.8.0-alpha.9":
version "0.8.0-alpha.9"
resolved "https://registry.npmjs.org/@nocobase/utils/-/utils-0.8.0-alpha.9.tgz#fa7e203412e91ab7a6ebb12a6d53d83e09465591"
integrity sha512-oEqKs4+0sO4x0TFEtf+Tf1nIkb9q+9y9YmrqOn6MyMo/ejTSh6nlNt1rAYgs02gTP4uHkSOb2/4K4oDm0JXLdg==
dependencies:
"@hapi/topo" "^6.0.0"
deepmerge "^4.2.2"
flat-to-nested "^1.1.1"
"@node-saml/node-saml@^4.0.2":
version "4.0.2"
resolved "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-4.0.2.tgz#e12020ea635346f33fcef008fe0e6fa2f28713e5"
@ -6234,6 +6389,21 @@ ahooks-v3-count@^1.0.0:
resolved "https://registry.yarnpkg.com/ahooks-v3-count/-/ahooks-v3-count-1.0.0.tgz#ddeb392e009ad6e748905b3cbf63a9fd8262ca80"
integrity sha512-V7uUvAwnimu6eh/PED4mCDjE7tokeZQLKlxg9lCTMPhN+NjsSbtdacByVlR1oluXQzD3MOw55wylDmQo4+S9ZQ==
ahooks@^3.0.5:
version "3.7.4"
resolved "https://registry.npmjs.org/ahooks/-/ahooks-3.7.4.tgz#25e76a989da324a855eb75758b789ab0644b7a59"
integrity sha512-hvgdqzPUKXn95mK3cGlDCi/ZZqv+FRibCUCFT8zW3hCwLGvixVfnHrIW2/2lgzPdLo8mLjp/XOdIJvcPvE2lgQ==
dependencies:
"@types/js-cookie" "^2.x.x"
ahooks-v3-count "^1.0.0"
dayjs "^1.9.1"
intersection-observer "^0.12.0"
js-cookie "^2.x.x"
lodash "^4.17.21"
resize-observer-polyfill "^1.5.1"
screenfull "^5.0.0"
tslib "^2.4.1"
ahooks@^3.7.2:
version "3.7.2"
resolved "https://registry.yarnpkg.com/ahooks/-/ahooks-3.7.2.tgz#0afa42625e77ae1cc4b60b19c45cf12a8cf29b56"
@ -7380,6 +7550,11 @@ before-after-hook@^2.2.0:
resolved "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e"
integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==
bessel@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/bessel/-/bessel-1.0.2.tgz#828812291e0b62e94959cdea43fac186e8a7202d"
integrity sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==
big.js@^5.2.2:
version "5.2.2"
resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@ -14747,6 +14922,11 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
jstat@^1.9.6:
version "1.9.6"
resolved "https://registry.npmjs.org/jstat/-/jstat-1.9.6.tgz#60e801b0d4c26e37aab0f375d1859fe9d60e10c0"
integrity sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug==
jstoxml@^2.0.0:
version "2.2.9"
resolved "https://registry.yarnpkg.com/jstoxml/-/jstoxml-2.2.9.tgz#2eebd5e55383fe66a375022ca0aa88f77bc4fb84"
@ -21020,6 +21200,11 @@ retry-as-promised@^6.1.0:
resolved "https://registry.yarnpkg.com/retry-as-promised/-/retry-as-promised-6.1.0.tgz#11eca9a0f97804d552ec8e74bc4eb839bd226dc4"
integrity sha512-Hj/jY+wFC+SB9SDlIIFWiGOHnNG0swYbGYsOj2BJ8u2HKUaobNKab0OIC0zOLYzDy0mb7A4xA5BMo4LMz5YtEA==
retry-as-promised@^7.0.3:
version "7.0.3"
resolved "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.3.tgz#ca3c13b15525a7bfbf0f56d2996f0e75649d068b"
integrity sha512-SEvMa4khHvpU/o6zgh7sK24qm6rxVgKnrSyzb5POeDvZx5N9Bf0s5sQsQ4Fl+HjRp0X+w2UzACGfUnXtx6cJ9Q==
retry@^0.12.0:
version "0.12.0"
resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
@ -21452,6 +21637,28 @@ sequelize@^6.26.0:
validator "^13.7.0"
wkx "^0.5.0"
sequelize@^6.9.0:
version "6.28.0"
resolved "https://registry.npmjs.org/sequelize/-/sequelize-6.28.0.tgz#d6bc4e36647e8501635467c0777c45a33f5d5ba8"
integrity sha512-+WHqvUQgTp19GLkt+gyQ+F6qg+FIEO2O5F9C0TOYV/PjZ2a/XwWvVkL1NCkS4VSIjVVvAUutiW6Wv9ofveGaVw==
dependencies:
"@types/debug" "^4.1.7"
"@types/validator" "^13.7.1"
debug "^4.3.3"
dottie "^2.0.2"
inflection "^1.13.2"
lodash "^4.17.21"
moment "^2.29.1"
moment-timezone "^0.5.34"
pg-connection-string "^2.5.0"
retry-as-promised "^7.0.3"
semver "^7.3.5"
sequelize-pool "^7.1.0"
toposort-class "^1.0.1"
uuid "^8.3.2"
validator "^13.7.0"
wkx "^0.5.0"
serialize-javascript@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
@ -23072,6 +23279,11 @@ tslib@^2.1.0, tslib@^2.2.0:
resolved "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
tslib@^2.4.1:
version "2.4.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
tsscmp@1.0.6:
version "1.0.6"
resolved "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"