feat(db): add sql collection (#2419)

* feat(db): add sql collection

* feat: frontend

* perf: issue of select

* fix: sql model

* fix: sql collection schema

* fix: implement sql collection

* fix: dependency

* fix: remove type declaration in actions

* fix: backend test

* chore: remove some ops of block using sql collection

* chore: remove sql collections from Form and Kanban

* feat: add execute button to sql input

* feat(backend): support infer fields by parsing sql

* feat(frontend): support infer interface by parsing sql

* fix: fix update issues and improve

* fix: update issue

* chore: update yarn.lock

* fix: fix T-1548

* fix: fix T-1544

* fix: fix T-1545

* fix: fix T-1549

* fix: test

* fix: fix T-1556

* fix: remove map action diviver

* chore: debug

* chore: remove schema of sql collection

* fix: sql collection schema

* chore: remove debug log & fix T-1555

* fix: fix T-1679

* fix: sql update issue

* fix: sql attribute issue

* fix: bug of star attribute

* fix: test

* fix: test

* fix: reset fields when updating sql collection

* fix(collection-manager): redundant fields after set collection fields

* fix: test

* fix: destory with individuals hook

* chore: save

* chore: test

* fix: fields sync issue

* fix: remove underscored option of sql collection

* chore: mutex in fields.afterDestroy

* fix: test

* chore: yarn.lock

* chore: update collections.setFields

* feat: improve sql input

* fix: fix T-1742 & improve

* chore: fix conflicts

* fix: workspace

* fix: build

* fix: test

* chore: add translations

* fix: reviewed issues

* chore: update yarn.lock

---------

Co-authored-by: ChengLei Shao <chareice@live.com>
This commit is contained in:
YANG QIA 2023-09-25 15:03:23 +08:00 committed by GitHub
parent 52198e57f9
commit 89635982b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1934 additions and 605 deletions

View File

@ -23,6 +23,7 @@ import {
SyncFieldsActionCom,
ViewCollectionField,
ViewFieldAction,
SyncSQLFieldsAction,
} from './Configuration';
import { CollectionCategroriesProvider } from './CollectionManagerProvider';
@ -80,6 +81,7 @@ export const CollectionManagerPane = () => {
EditCategoryAction,
SyncFieldsAction,
SyncFieldsActionCom,
SyncSQLFieldsAction,
}}
/>
// </Card>

View File

@ -98,7 +98,7 @@ const getSchema = (schema, category, compile): ISchema => {
};
const getDefaultCollectionFields = (values) => {
if (values?.template === 'view') {
if (values?.template === 'view' || values?.template === 'sql') {
return values.fields;
}
const defaults = values.fields ? [...values.fields] : [];

View File

@ -302,6 +302,7 @@ export const AddFieldAction = (props) => {
};
}, [getInterface, items, record]);
return (
record.template !== 'sql' && (
<RecordProvider record={record}>
<ActionContextProvider value={{ visible, setVisible }}>
<Dropdown getPopupContainer={getContainer} trigger={trigger} align={align} menu={menu}>
@ -332,5 +333,6 @@ export const AddFieldAction = (props) => {
/>
</ActionContextProvider>
</RecordProvider>
)
);
};

View File

@ -25,6 +25,7 @@ import { OverridingCollectionField } from './OverridingCollectionField';
import { collection } from './schemas/collectionFields';
import { SyncFieldsAction } from './SyncFieldsAction';
import { ViewCollectionField } from './ViewInheritedField';
import { SyncSQLFieldsAction } from './SyncSQLFieldsAction';
import { Input } from '../../schema-component/antd/input';
const indentStyle = css`
@ -419,6 +420,7 @@ export const CollectionFields = () => {
>
<Action {...deleteProps} />
<SyncFieldsAction {...syncProps} />
<SyncSQLFieldsAction refreshCMList={refreshAsync} />
<AddCollectionField {...addProps} />
</Space>
<Table

View File

@ -5,7 +5,7 @@ import cloneDeep from 'lodash/cloneDeep';
import omit from 'lodash/omit';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useRequest } from '../../api-client';
import { useAPIClient, useRequest } from '../../api-client';
import { RecordProvider, useRecord } from '../../record-provider';
import { ActionContextProvider, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
@ -100,11 +100,16 @@ export const useUpdateCollectionActionAndRefreshCM = (options) => {
const ctx = useActionContext();
const { refresh } = useResourceActionContext();
const { resource, targetKey } = useResourceContext();
const { [targetKey]: filterByTk } = useRecord();
const { [targetKey]: filterByTk, template } = useRecord();
const api = useAPIClient();
const collectionResource = template === 'sql' ? api.resource('sqlCollection') : resource;
return {
async run() {
await form.submit();
await resource.update({ filterByTk, values: omit(form.values, ['fields']) });
await collectionResource.update({
filterByTk,
values: template === 'sql' ? form.values : omit(form.values, ['fields']),
});
ctx.setVisible(false);
await form.reset();
refresh();

View File

@ -0,0 +1,160 @@
import { RecordProvider, useRecord } from '../../record-provider';
import React, { useEffect, useMemo, useState } from 'react';
import { ActionContextProvider, FormProvider, SchemaComponent, useActionContext } from '../../schema-component';
import { SyncOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
import { uid } from '@formily/shared';
import { useCancelAction } from '../action-hooks';
import { FieldsConfigure, PreviewTable, SQLRequestProvider } from '../templates/components/sql-collection';
import { createForm } from '@formily/core';
import { FormLayout } from '@formily/antd-v5';
import { useCollectionManager } from '../hooks';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import { useAPIClient } from '../../api-client';
import { useField, useForm } from '@formily/react';
const schema = {
type: 'object',
properties: {
[uid()]: {
type: 'void',
title: '{{ t("Sync from database") }}',
'x-component': 'Action.Drawer',
'x-decorator': 'FormLayout',
'x-decorator-props': {
layout: 'vertical',
},
properties: {
config: {
type: 'void',
'x-decorator': SQLRequestProvider,
'x-decorator-props': {
manual: false,
},
properties: {
sql: {
type: 'string',
},
sources: {
type: 'array',
title: '{{t("Source collections")}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'],
},
fields: {
type: 'array',
title: '{{t("Fields")}}',
'x-decorator': 'FormItem',
'x-component': FieldsConfigure,
required: true,
},
table: {
type: 'void',
title: '{{t("Preview")}}',
'x-decorator': 'FormItem',
'x-component': PreviewTable,
},
},
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
submit: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useSyncFromDB }}',
},
},
},
},
},
},
},
};
const useSyncFromDB = (refreshCMList?: any) => {
const form = useForm();
const ctx = useActionContext();
const { refreshCM } = useCollectionManager();
const { refresh } = useResourceActionContext();
const { targetKey } = useResourceContext();
const { [targetKey]: filterByTk } = useRecord();
const api = useAPIClient();
const field = useField();
return {
async run() {
await form.submit();
field.data = field.data || {};
field.data.loading = true;
try {
await api.resource('sqlCollection').setFields({
filterByTk,
values: {
fields: form.values.fields,
sources: form.values.sources,
},
});
ctx.setVisible(false);
await form.reset();
field.data.loading = false;
refresh();
await refreshCM();
await refreshCMList?.();
} catch (err) {
field.data.loading = false;
}
},
};
};
export const SyncSQLFieldsAction: React.FC<{
refreshCMList: any;
}> = ({ refreshCMList }) => {
const record = useRecord();
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const form = useMemo(
() =>
createForm({
initialValues: record,
}),
[record],
);
return (
record.template === 'sql' && (
<RecordProvider record={record}>
<FormProvider form={form}>
<ActionContextProvider value={{ visible, setVisible }}>
<Button icon={<SyncOutlined />} onClick={(e) => setVisible(true)}>
{t('Sync from database')}
</Button>
<SchemaComponent
schema={schema}
components={{ FormLayout }}
scope={{
useCancelAction,
useSyncFromDB: () => useSyncFromDB(refreshCMList),
}}
/>
</ActionContextProvider>
</FormProvider>
</RecordProvider>
)
);
};

View File

@ -15,6 +15,7 @@ export * from './ConfigurationTabs';
export * from './AddCategoryAction';
export * from './EditCategoryAction';
export * from './SyncFieldsAction';
export * from './SyncSQLFieldsAction';
registerValidateFormats({
uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/,

View File

@ -124,6 +124,14 @@ export const collectionFieldSchema: ISchema = {
type: 'primary',
},
},
syncSQL: {
type: 'void',
title: '{{ t("Sync from database") }}',
'x-component': 'SyncSQLFieldsAction',
'x-component-props': {
type: 'primary',
},
},
create: {
type: 'void',
title: '{{ t("Add new") }}',

View File

@ -0,0 +1,291 @@
import { useTranslation } from 'react-i18next';
import { useAsyncData } from '../../../../async-data-provider';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Cascader, Input, Select, Spin, Table, Tag } from 'antd';
import { observer, useField, useForm } from '@formily/react';
import { ArrayField } from '@formily/core';
import { getOptions } from '../../../Configuration/interfaces';
import { useCompile } from '../../../../schema-component';
import { useCollectionManager } from '../../../hooks';
import dayjs from 'dayjs';
import { FieldOptions } from '@nocobase/database';
import { ResourceActionContext, useResourceContext } from '../../../ResourceActionProvider';
import { useRecord } from '../../../../record-provider';
import { last } from 'lodash';
const inferInterface = (field: string, value: any) => {
if (field.toLowerCase().includes('id')) {
return 'id';
}
if (typeof value === 'number') {
if (Number.isInteger(value)) {
return 'integer';
}
return 'number';
}
if (typeof value === 'boolean') {
return 'boolean';
}
if (dayjs(value).isValid()) {
return 'datetime';
}
return 'input';
};
const useSourceFieldsOptions = () => {
const form = useForm();
const { sources = [] } = form.values;
const { t } = useTranslation();
const { getCollection, getInheritCollections, getParentCollectionFields } = useCollectionManager();
const data = [];
sources.forEach((item: string) => {
const collection = getCollection(item);
const inherits = getInheritCollections(item);
const result = inherits.map((v) => {
const fields: FieldOptions[] = getParentCollectionFields(v, item);
return {
type: 'group',
key: v,
label: t(`Parent collection fields`) + t(`(${getCollection(v).title})`),
children: fields
.filter((v) => !['hasOne', 'hasMany', 'belongsToMany'].includes(v?.type))
.map((k) => {
return {
value: k.name,
label: t(k.uiSchema?.title),
};
}),
};
});
const children = (collection.fields as FieldOptions[])
.filter((v) => !['hasOne', 'hasMany', 'belongsToMany'].includes(v?.type))
?.map((v) => {
return { value: v.name, label: t(v.uiSchema?.title) };
});
data.push({
value: item,
label: t(collection.title),
children: [...children, ...result],
});
});
return data;
};
export const FieldsConfigure = observer(() => {
const { t } = useTranslation();
const [dataSource, setDataSource] = useState([]);
const { data: res, error, loading } = useAsyncData();
const { data, fields: sourceFields } = res || {};
const field: ArrayField = useField();
const { data: curFields } = useContext(ResourceActionContext);
const compile = useCompile();
const { getInterface, getCollectionField } = useCollectionManager();
const interfaceOptions = useMemo(
() =>
getOptions()
.filter((v) => !['relation'].includes(v.key))
.map((options, index) => ({
...options,
key: index,
label: compile(options.label),
options: options.children.map((option) => ({
...option,
label: compile(option.label),
})),
})),
[compile],
);
const sourceFieldsOptions = useSourceFieldsOptions();
const refGetInterface = useRef(getInterface);
useEffect(() => {
const fieldsMp = new Map();
if (!loading) {
if (data && data.length) {
Object.entries(data?.[0] || {}).forEach(([col, val]) => {
const sourceField = sourceFields[col];
const fieldInterface = inferInterface(col, val);
const defaultConfig = refGetInterface.current(fieldInterface)?.default;
const uiSchema = sourceField?.uiSchema || defaultConfig?.uiSchema || {};
fieldsMp.set(col, {
name: col,
interface: sourceField?.interface || fieldInterface,
type: sourceField?.type || defaultConfig?.type,
source: sourceField?.source,
uiSchema: {
title: col,
...uiSchema,
},
});
});
} else {
Object.entries(sourceFields || {}).forEach(([col, val]: [string, any]) =>
fieldsMp.set(col, {
name: col,
...val,
uiSchema: {
title: col,
...(val?.uiSchema || {}),
},
}),
);
}
}
if (field.value?.length) {
field.value.forEach((item) => {
if (fieldsMp.has(item.name)) {
fieldsMp.set(item.name, item);
}
});
}
// if (curFields?.data.length) {
// curFields.data.forEach((field: any) => {
// if (fieldsMp.has(field.name)) {
// fieldsMp.set(field.name, field);
// }
// });
// }
const fields = Array.from(fieldsMp.values());
if (!fields.length) {
return;
}
setDataSource(fields);
field.setValue(fields);
}, [loading, data, field, sourceFields, curFields]);
if (loading) {
return <Spin />;
}
if (!data && !error) {
return <Alert showIcon message={t('Please use a valid SELECT or WITH AS statement')} />;
}
const err = error as any;
if (err) {
const errMsg =
err?.response?.data?.errors?.map?.((item: { message: string }) => item.message).join('\n') || err.message;
return <Alert showIcon message={`${t('SQL error: ')}${errMsg}`} type="error" />;
}
const handleFieldChange = (record: any, index: number) => {
const fields = [...dataSource];
fields.splice(index, 1, record);
setDataSource(fields);
field.setValue(
fields.map((f) => ({
...f,
source: typeof f.source === 'string' ? f.source : f.source?.filter?.(Boolean)?.join('.') || null,
})),
);
};
const columns = [
{
title: t('Field name'),
dataIndex: 'name',
key: 'name',
width: 130,
},
{
title: t('Field source'),
dataIndex: 'source',
key: 'source',
width: 200,
render: (text: string, record: any, index: number) => {
const field = dataSource[index];
return (
<Cascader
defaultValue={typeof text === 'string' ? text?.split('.') : text}
allowClear
options={compile(sourceFieldsOptions)}
placeholder={t('Select field source')}
onChange={(value: string[]) => {
let sourceField = sourceFields[value?.[1]];
if (!sourceField) {
sourceField = getCollectionField(value?.join('.') || '');
}
handleFieldChange(
{
...field,
source: value,
interface: sourceField?.interface,
type: sourceField?.type,
uiSchema: sourceField?.uiSchema,
},
index,
);
}}
/>
);
},
},
{
title: t('Field interface'),
dataIndex: 'interface',
key: 'interface',
width: 150,
render: (text: string, record: any, index: number) => {
const field = dataSource[index];
return field.source ? (
<Tag>{compile(getInterface(text)?.title) || text}</Tag>
) : (
<Select
defaultValue={field.interface || 'input'}
style={{ width: '100%' }}
popupMatchSelectWidth={false}
onChange={(value) => {
const interfaceConfig = getInterface(value);
handleFieldChange(
{
...field,
interface: value || null,
uiSchema: {
...interfaceConfig?.default?.uiSchema,
title: interfaceConfig?.default?.uiSchema?.title || field.uiSchema?.title,
},
type: interfaceConfig?.default?.type,
},
index,
);
}}
allowClear={true}
options={interfaceOptions}
/>
);
},
},
{
title: t('Field display name'),
dataIndex: 'title',
key: 'title',
width: 180,
render: (text: string, record: any, index: number) => {
const field = dataSource[index];
return (
<Input
value={field.uiSchema?.title || text}
defaultValue={field.uiSchema?.title !== undefined ? field.uiSchema.title : field?.name}
onChange={(e) =>
handleFieldChange({ ...field, uiSchema: { ...field?.uiSchema, title: e.target.value } }, index)
}
/>
);
},
},
];
return (
<Table
bordered
size="small"
columns={columns}
dataSource={dataSource}
scroll={{ y: 300 }}
pagination={false}
rowClassName="editable-row"
rowKey="name"
/>
);
});

View File

@ -0,0 +1,58 @@
import { useAsyncData } from '../../../../async-data-provider';
import React, { useEffect } from 'react';
import { Table } from 'antd';
import { Schema, observer, useForm } from '@formily/react';
import { useTranslation } from 'react-i18next';
export const PreviewTable = observer(() => {
const { data: res, loading, error } = useAsyncData();
const { data } = res || {};
const { t } = useTranslation();
const form = useForm();
const fields = form.values.fields || [];
const titleMp = fields.reduce((mp: { [name: string]: string }, field: any) => {
mp[field.name] = field?.uiSchema?.title;
return mp;
}, {});
const columns = error
? []
: Object.keys(data?.[0] || {}).map((col) => {
const title = titleMp[col];
return {
title: Schema.compile(title || col, { t }),
dataIndex: col,
key: col,
};
});
const dataSource = error
? []
: data?.map((record: any, index: number) => {
const compiledRecord = Object.entries(record).reduce(
(mp: { [key: string]: any }, [key, val]: [string, any]) => {
if (typeof val !== 'string') {
mp[key] = val;
return mp;
}
const compiled = Schema.compile(val, { t });
mp[key] = t(compiled);
return mp;
},
{},
);
return { ...compiledRecord, key: index };
});
return (
<Table
bordered
dataSource={dataSource}
columns={columns}
scroll={{ x: columns.length * 150, y: 300 }}
loading={loading}
rowKey="key"
/>
);
});

View File

@ -0,0 +1,68 @@
import { useField, useForm } from '@formily/react';
import { useAsyncData } from '../../../../async-data-provider';
import React, { useEffect } from 'react';
import { Input, SchemaComponent } from '../../../../schema-component';
import { css } from '@emotion/css';
import { Button } from 'antd';
import { EditOutlined, RightSquareOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { Field } from '@formily/core';
export const SQLInput = ({ disabled }) => {
const { t } = useTranslation();
const { run, loading, error } = useAsyncData();
const field = useField<Field>();
const execute = () => {
if (!field.value) {
return;
}
run(field.value);
};
const toggleEditing = () => {
if (!disabled && !field.value) {
return;
}
if (!disabled) {
run(field.value);
}
field.setComponentProps({
disabled: !disabled,
});
};
useEffect(() => {
if (error) {
field.setComponentProps({
disabled: false,
});
}
}, [field, error]);
return (
<div
className={css`
position: relative;
.ant-input {
width: 100%;
}
`}
>
<Input.TextArea value={field.value} disabled={disabled} onChange={(e) => (field.value = e.target.value)} />
<Button.Group>
<Button onClick={toggleEditing} ghost size="small" type="primary" icon={<EditOutlined />}>
{t(!disabled ? 'Confirm' : 'Edit')}
</Button>
<Button
onClick={() => execute()}
loading={loading}
ghost
size="small"
type="primary"
icon={<RightSquareOutlined />}
>
{t('Execute')}
</Button>
</Button.Group>
</div>
);
};

View File

@ -0,0 +1,47 @@
import { useAPIClient, useRequest } from '../../../../api-client';
import { AsyncDataProvider } from '../../../../async-data-provider';
import React, { useEffect, useRef } from 'react';
import { useForm } from '@formily/react';
import { useRecord } from '../../../../record-provider';
export const SQLRequestProvider: React.FC<{
manual?: boolean;
}> = (props) => {
const api = useAPIClient();
const form = useForm();
const record = useRecord();
let { manual } = props;
manual = manual === undefined ? true : manual;
const result = useRequest(
(sql: string) =>
api
.resource('sqlCollection')
.execute({
values: {
sql,
},
})
.then((res) => res?.data?.data || { data: [], fields: [], sources: [] }),
{
manual: true,
onSuccess: (data) => {
const { sources } = data;
const formSources = form.values.sources || [];
form.setValuesIn('sources', Array.from(new Set([...formSources, ...sources])));
},
},
);
const { run } = result;
const sql = form.values.sql || record.sql;
const first = useRef(true);
useEffect(() => {
if (sql && first.current) {
run(sql);
}
first.current = false;
}, [manual, run, sql]);
return <AsyncDataProvider value={result}>{props.children}</AsyncDataProvider>;
};

View File

@ -0,0 +1,4 @@
export * from './FieldsConfigure';
export * from './PreviewTable';
export * from './SQLRequestProvider';
export * from './SQLInput';

View File

@ -3,3 +3,4 @@ export * from './general';
export * from './tree';
export * from './expression';
export * from './view';
export * from './sql';

View File

@ -0,0 +1,80 @@
import { Field } from '@formily/core';
import { useAsyncData } from '../../async-data-provider';
import { SQLInput, PreviewTable, FieldsConfigure, SQLRequestProvider } from './components/sql-collection';
import { getConfigurableProperties } from './properties';
import { ICollectionTemplate } from './types';
import { i18n } from '../../i18n';
export const sql: ICollectionTemplate = {
name: 'sql',
title: '{{t("SQL collection")}}',
order: 4,
color: 'yellow',
default: {
fields: [],
},
configurableProperties: {
title: {
type: 'string',
title: '{{ t("Collection display name") }}',
required: true,
'x-decorator': 'FormItem',
'x-component': 'Input',
},
name: {
type: 'string',
title: '{{t("Collection name")}}',
required: true,
'x-disabled': '{{ !createOnly }}',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-validator': 'uid',
description:
"{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
},
config: {
type: 'void',
'x-decorator': SQLRequestProvider,
properties: {
sql: {
type: 'string',
title: '{{t("SQL")}}',
'x-decorator': 'FormItem',
'x-component': SQLInput,
required: true,
'x-validator': (value: string, rules, { form }) => {
const field = form.query('sql').take() as Field;
if (!field.componentProps.disabled) {
return i18n.t('Please confirm the SQL statement first');
}
return '';
},
},
sources: {
type: 'array',
title: '{{t("Source collections")}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'],
},
fields: {
type: 'array',
title: '{{t("Fields")}}',
'x-decorator': 'FormItem',
'x-component': FieldsConfigure,
required: true,
},
table: {
type: 'void',
title: '{{t("Preview")}}',
'x-decorator': 'FormItem',
'x-component': PreviewTable,
},
},
},
...getConfigurableProperties('category'),
},
};

View File

@ -117,485 +117,498 @@ export default {
'Tablet device': 'Tablet device',
'Desktop device': 'Desktop device',
'Large screen device': 'Large screen device',
"Collapse": "Collapse",
"Select data source": "Select data source",
"Calendar": "Calendar",
"Delete events": "Delete events",
"This event": "This event",
"This and following events": "This and following events",
"All events": "All events",
"Delete this event?": "Delete this event?",
"Delete Event": "Delete Event",
"Kanban": "Kanban",
"Gantt":"Gantt",
"Create gantt block":"Create gantt block",
"Progress field":"Progress field",
"Time scale":"Time scale",
"Hour":"Hour",
"Quarter of day":"Quarter of day",
"Half of day":"Half of day",
"Year":"Year",
"QuarterYear":"QuarterYear",
"Select grouping field": "Select grouping field",
"Media": "Media",
"Markdown": "Markdown",
"Wysiwyg": "Wysiwyg",
"Chart blocks": "Chart blocks",
"Column chart": "Column chart",
"Bar chart": "Bar chart",
"Line chart": "Line chart",
"Pie chart": "Pie chart",
"Area chart": "Area chart",
"Other chart": "Other chart",
"Other blocks": "Other blocks",
"In configuration": "In configuration",
"Chart title": "Chart title",
"Chart type": "Chart type",
"Chart config": "Chart config",
"Templates": "Templates",
"Select template": "Select template",
"Action logs": "Action logs",
"Create template": "Create template",
"Edit markdown": "Edit markdown",
"Add block": "Add block",
"Add new": "Add new",
"Add record": "Add record",
'Add child':'Add child',
'Collapse all':'Collapse all',
'Expand all':'Expand all',
'Expand/Collapse':'Expand/Collapse',
'Default collapse':'Default collapse',
"Tree table":"Tree table",
"Custom field display name": "Custom field display name",
"Display fields": "Display collection fields",
"Edit record": "Edit record",
"Delete menu item": "Delete menu item",
"Add page": "Add page",
"Add group": "Add group",
"Add link": "Add link",
"Insert above": "Insert above",
"Insert below": "Insert below",
"Save": "Save",
"Delete block": "Delete block",
"Are you sure you want to delete it?": "Are you sure you want to delete it?",
"This is a demo text, **supports Markdown syntax**.": "This is a demo text, **supports Markdown syntax**.",
"Filter": "Filter",
"Connect data blocks": "Connect data blocks",
"Action type": "Action type",
"Actions": "Actions",
"Insert": "Insert",
"Insert if not exists": "Insert if not exists",
"Insert if not exists, or update": "Insert if not exists, or update",
"Determine whether a record exists by the following fields": "Determine whether a record exists by the following fields",
"Update": "Update",
"View": "View",
"View record": "View record",
"Refresh": "Refresh",
"Data changes": "Data changes",
"Field name": "Field name",
"Before change": "Before change",
"After change": "After change",
"Delete record": "Delete record",
"Create collection": "Create collection",
"Collection display name": "Collection display name",
"Collection name": "Collection name",
"Inherits": "Inherits",
"Generate ID field automatically": "Generate ID field automatically",
"Store the creation user of each record": "Store the creation user of each record",
"Store the last update user of each record": "Store the last update user of each record",
"Store the creation time of each record": "Store the creation time of each record",
"Store the last update time of each record": "Store the last update time of each record",
"More options": "More options",
"Records can be sorted": "Records can be sorted",
"Calendar collection": "Calendar collection",
"General collection": "General collection",
"Connect to database view":"Connect to database view",
"Source collections":"Source collections",
"Field source":"Field source",
"Preview":"Preview",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.",
"Edit": "Edit",
"Edit collection": "Edit collection",
"Configure fields": "Configure fields",
"Configure columns": "Configure columns",
"Edit field": "Edit field",
"Override": "Override",
"Override field": "Override field",
"Configure fields of {{title}}": "Configure fields of {{title}}",
"Association fields filter": "Association fields filter",
"PK & FK fields": "PK & FK fields",
"Association fields": "Association fields",
"Choices fields": "Choices fields",
"System fields": "System fields",
"General fields": "General fields",
"Inherited fields": "Inherited fields",
"Parent collection fields": "Parent collection fields",
"Basic": "Basic",
"Single line text": "Single line text",
"Long text": "Long text",
"Phone": "Phone",
"Email": "Email",
"Number": "Number",
"Integer": "Integer",
"Percent": "Percent",
"Password": "Password",
"Advanced type": "Advanced",
"Formula": "Formula",
"Formula description": "Compute a value in each record based on other fields in the same record.",
"Choices": "Choices",
"Checkbox": "Checkbox",
"Single select": "Single select",
"Multiple select": "Multiple select",
"Radio group": "Radio group",
"Checkbox group": "Checkbox group",
"China region": "China region",
"Date & Time": "Date & Time",
"Datetime": "Datetime",
"Relation": "Relation",
"Link to": "Link to",
"Link to description": "Used to create collection relationships quickly and compatible with most common scenarios. Suitable for non-developer use. When present as a field, it is a drop-down selection used to select records from the target collection. Once created, it will simultaneously generate the associated fields of the current collection in the target collection.",
"Sub-table": "Sub-table",
"Sub-details":"Sub-details",
"Sub-form(Popover)":"Sub-form(Popover)",
"System info": "System info",
"Created at": "Created at",
"Last updated at": "Last updated at",
"Created by": "Created by",
"Last updated by": "Last updated by",
"Add field": "Add field",
"Field display name": "Field display name",
"Field type": "Field type",
"Field interface": "Field interface",
"Date format": "Date format",
"Year/Month/Day": "Year/Month/Day",
"Year-Month-Day": "Year-Month-Day",
"Day/Month/Year": "Day/Month/Year",
"Show time": "Show time",
"Time format": "Time format",
"12 hour": "12 hour",
"24 hour": "24 hour",
"Relationship type": "Relationship type",
"Inverse relationship type": "Inverse relationship type",
"Source collection": "Source collection",
"Source key": "Source key",
"Target collection": "Target collection",
"Through collection": "Through collection",
"Target key": "Target key",
"Foreign key": "Foreign key",
"One to one": "One to one",
"One to many": "One to many",
"Many to one": "Many to one",
"Many to many": "Many to many",
"Foreign key 1": "Foreign key 1",
"Foreign key 2": "Foreign key 2",
"One to one description": "Used to create one-to-one relationships. For example, a user has a profile.",
"One to many description": "Used to create a one-to-many relationship. For example, a country will have many cities and a city can only be in one country. When present as a field, it is a sub-table that displays the records of the associated collection. When created, a Many-to-one field is automatically generated in the associated collection.",
"Many to one description": "Used to create many-to-one relationships. For example, a city can belong to only one country and a country can have many cities. When present as a field, it is a drop-down selection used to select record from the associated collection. Once created, a One-to-many field is automatically generated in the associated collection.",
"Many to many description": "Used to create many-to-many relationships. For example, a student will have many teachers and a teacher will have many students. When present as a field, it is a drop-down selection used to select records from the associated collection.",
"Generated automatically if left blank": "Generated automatically if left blank",
"Display association fields": "Display association fields",
"Display field title": "Display field title",
"Field component": "Field component",
"Allow multiple": "Allow multiple",
"Quick upload": "Quick upload",
"Select file": "Select file",
"Subtable": "Sub-table",
"Sub-form": "Sub-form",
"Field mode":"Field mode",
"Allow add new data":"Allow add new data",
"Record picker": "Record picker",
"Toggles the subfield mode": "Toggles the subfield mode",
"Selector mode": "Selector mode",
"Subtable mode": "Sub-table mode",
"Subform mode": "Sub-form mode",
"Edit block title": "Edit block title",
"Block title": "Block title",
"Pattern": "Pattern",
"Operator": "Operator",
"Editable": "Editable",
"Readonly": "Readonly",
"Easy-reading": "Easy-reading",
"Add filter": "Add filter",
"Add filter group": "Add filter group",
"Comparision": "Comparision",
"is": "is",
"is not": "is not",
"contains": "contains",
"does not contain": "does not contain",
"starts with": "starts with",
"not starts with": "not starts with",
"ends with": "ends with",
"not ends with": "not ends with",
"is empty": "is empty",
"is not empty": "is not empty",
"Edit chart": "Edit chart",
"Add text": "Add text",
"Filterable fields": "Filterable fields",
"Edit button": "Edit button",
"Hide": "Hide",
"Enable actions": "Enable actions",
"Import": "Import",
"Export": "Export",
"Customize": "Customize",
"Custom": "Custom",
"Function": "Function",
"Popup form": "Popup form",
"Flexible popup": "Flexible popup",
"Configure actions": "Configure actions",
"Display order number": "Display order number",
"Enable drag and drop sorting": "Enable drag and drop sorting",
"Triggered when the row is clicked": "Triggered when the row is clicked",
"Add tab": "Add tab",
"Disable tabs": "Disable tabs",
"Details": "Details",
"Edit tab": "Edit tab",
"Relationship blocks": "Relationship blocks",
"Select record": "Select record",
"Display name": "Display name",
"Select icon": "Select icon",
"Custom column name": "Custom column name",
"Edit description": "Edit description",
"Required": "Required",
"Unique": "Unique",
"Label field": "Label field",
"Default is the ID field": "Default is the ID field",
"Set default sorting rules": "Set default sorting rules",
"Set validation rules": "Set validation rules",
"Max length": "Max length",
"Min length": "Min length",
"Maximum": "Maximum",
"Minimum": "Minimum",
"Max length must greater than min length": "Max length must greater than min length",
"Min length must less than max length": "Min length must less than max length",
"Maximum must greater than minimum": "Maximum must greater than minimum",
"Minimum must less than maximum": "Minimum must less than maximum",
"Validation rule": "Validation rule",
"Add validation rule": "Add validation rule",
"Format": "Format",
"Regular expression": "Pattern",
"Error message": "Error message",
"Length": "Length",
"The field value cannot be greater than ": "The field value cannot be greater than ",
"The field value cannot be less than ": "The field value cannot be less than ",
"The field value is not an integer number": "The field value is not an integer number",
"Set default value": "Set default value",
"Default value": "Default value",
"is before": "is before",
"is after": "is after",
"is on or after": "is on or after",
"is on or before": "is on or before",
"is between": "is between",
"Upload": "Upload",
"Select level": "Select level",
"Province": "Province",
"City": "City",
"Area": "Area",
"Street": "Street",
"Village": "Village",
"Must select to the last level": "Must select to the last level",
"Move {{title}} to": "Move {{title}} to",
"Target position": "Target position",
"After": "After",
"Before": "Before",
"Add {{type}} before \"{{title}}\"": "Add {{type}} before \"{{title}}\"",
"Add {{type}} after \"{{title}}\"": "Add {{type}} after \"{{title}}\"",
"Add {{type}} in \"{{title}}\"": "Add {{type}} in \"{{title}}\"",
"Original name": "Original name",
"Custom name": "Custom name",
"Custom Title": "Custom Title",
"Options": "Options",
"Option value": "Option value",
"Option label": "Option label",
"Color": "Color",
"Add option": "Add option",
"Related collection": "Related collection",
"Allow linking to multiple records": "Allow linking to multiple records",
"Allow uploading multiple files": "Allow uploading multiple files",
"Configure calendar": "Configure calendar",
"Title field": "Title field",
"Custom title": "Custom title",
"Daily": "Daily",
"Weekly": "Weekly",
"Monthly": "Monthly",
"Yearly": "Yearly",
"Repeats": "Repeats",
"Show lunar": "Show lunar",
"Start date field": "Start date field",
"End date field": "End date field",
"Navigate": "Navigate",
"Title": "Title",
"Description": "Description",
"Select view": "Select view",
"Reset": "Reset",
"Importable fields": "Importable fields",
"Exportable fields": "Exportable fields",
"Saved successfully": "Saved successfully",
"Nickname": "Nickname",
"Sign in": "Sign in",
"Sign in via account": "Sign in via account",
"Sign in via phone": "Sign in via phone",
"Create an account": "Create an account",
"Sign up": "Sign up",
"Confirm password": "Confirm password",
"Log in with an existing account": "Log in with an existing account",
"Signed up successfully. It will jump to the login page.": "Signed up successfully. It will jump to the login page.",
"Password mismatch": "Password mismatch",
"Users": "Users",
"Verification code": "Verification code",
"Send code": "Send code",
"Retry after {{count}} seconds": "Retry after {{count}} seconds",
"Roles": "Roles",
"Add role": "Add role",
"Role name": "Role name",
"Configure": "Configure",
"Configure permissions": "Configure permissions",
"Edit role": "Edit role",
"Action permissions": "Action permissions",
"Menu permissions": "Menu permissions",
"Menu item name": "Menu item name",
"Allow access": "Allow access",
"Action name": "Action name",
"Allow action": "Allow action",
"Action scope": "Action scope",
"Operate on new data": "Operate on new data",
"Operate on existing data": "Operate on existing data",
"Yes": "Yes",
"No": "No",
"Red": "Red",
"Magenta": "Magenta",
"Volcano": "Volcano",
"Orange": "Orange",
"Gold": "Gold",
"Lime": "Lime",
"Green": "Green",
"Cyan": "Cyan",
"Blue": "Blue",
"Geek blue": "Geek blue",
"Purple": "Purple",
"Default": "Default",
"Add card": "Add card",
"edit title": "edit title",
"Turn pages": "Turn pages",
"Others": "Others",
"Save as template": "Save as template",
"Save as block template": "Save as block template",
"Block templates": "Block templates",
"Convert reference to duplicate": "Convert reference to duplicate",
"Template name": "Template name",
"Block type": "Block type",
"No blocks to connect": "No blocks to connect",
"Action column": "Action column",
"Records per page": "Records per page",
"(Fields only)": "(Fields only)",
"Button title": "Button title",
"Button icon": "Button icon",
"Submitted successfully": "Submitted successfully",
"Operation succeeded": "Operation succeeded",
"Operation failed": "Operation failed",
"Open mode": "Open mode",
"Popup size": "Popup size",
"Small": "Small",
"Middle": "Middle",
"Large": "Large",
"Menu item title": "Menu item title",
"Menu item icon": "Menu item icon",
"Target": "Target",
"Position": "Position",
"Insert before": "Insert before",
"Insert after": "Insert after",
"UI Editor": "UI Editor",
"ASC": "ASC",
"DESC": "DESC",
"Add sort field": "Add sort field",
"ID": "ID",
"Identifier for program usage. Support letters, numbers and underscores, must start with an letter.": "Identifier for program usage. Support letters, numbers and underscores, must start with an letter.",
"Drawer": "Drawer",
"Dialog": "Dialog",
"Delete action": "Delete action",
"Custom column title": "Custom column title",
Collapse: 'Collapse',
'Select data source': 'Select data source',
Calendar: 'Calendar',
'Delete events': 'Delete events',
'This event': 'This event',
'This and following events': 'This and following events',
'All events': 'All events',
'Delete this event?': 'Delete this event?',
'Delete Event': 'Delete Event',
Kanban: 'Kanban',
Gantt: 'Gantt',
'Create gantt block': 'Create gantt block',
'Progress field': 'Progress field',
'Time scale': 'Time scale',
Hour: 'Hour',
'Quarter of day': 'Quarter of day',
'Half of day': 'Half of day',
Year: 'Year',
QuarterYear: 'QuarterYear',
'Select grouping field': 'Select grouping field',
Media: 'Media',
Markdown: 'Markdown',
Wysiwyg: 'Wysiwyg',
'Chart blocks': 'Chart blocks',
'Column chart': 'Column chart',
'Bar chart': 'Bar chart',
'Line chart': 'Line chart',
'Pie chart': 'Pie chart',
'Area chart': 'Area chart',
'Other chart': 'Other chart',
'Other blocks': 'Other blocks',
'In configuration': 'In configuration',
'Chart title': 'Chart title',
'Chart type': 'Chart type',
'Chart config': 'Chart config',
Templates: 'Templates',
'Select template': 'Select template',
'Action logs': 'Action logs',
'Create template': 'Create template',
'Edit markdown': 'Edit markdown',
'Add block': 'Add block',
'Add new': 'Add new',
'Add record': 'Add record',
'Add child': 'Add child',
'Collapse all': 'Collapse all',
'Expand all': 'Expand all',
'Expand/Collapse': 'Expand/Collapse',
'Default collapse': 'Default collapse',
'Tree table': 'Tree table',
'Custom field display name': 'Custom field display name',
'Display fields': 'Display collection fields',
'Edit record': 'Edit record',
'Delete menu item': 'Delete menu item',
'Add page': 'Add page',
'Add group': 'Add group',
'Add link': 'Add link',
'Insert above': 'Insert above',
'Insert below': 'Insert below',
Save: 'Save',
'Delete block': 'Delete block',
'Are you sure you want to delete it?': 'Are you sure you want to delete it?',
'This is a demo text, **supports Markdown syntax**.': 'This is a demo text, **supports Markdown syntax**.',
Filter: 'Filter',
'Connect data blocks': 'Connect data blocks',
'Action type': 'Action type',
Actions: 'Actions',
Insert: 'Insert',
'Insert if not exists': 'Insert if not exists',
'Insert if not exists, or update': 'Insert if not exists, or update',
'Determine whether a record exists by the following fields':
'Determine whether a record exists by the following fields',
Update: 'Update',
View: 'View',
'View record': 'View record',
Refresh: 'Refresh',
'Data changes': 'Data changes',
'Field name': 'Field name',
'Before change': 'Before change',
'After change': 'After change',
'Delete record': 'Delete record',
'Create collection': 'Create collection',
'Collection display name': 'Collection display name',
'Collection name': 'Collection name',
Inherits: 'Inherits',
'Generate ID field automatically': 'Generate ID field automatically',
'Store the creation user of each record': 'Store the creation user of each record',
'Store the last update user of each record': 'Store the last update user of each record',
'Store the creation time of each record': 'Store the creation time of each record',
'Store the last update time of each record': 'Store the last update time of each record',
'More options': 'More options',
'Records can be sorted': 'Records can be sorted',
'Calendar collection': 'Calendar collection',
'General collection': 'General collection',
'Connect to database view': 'Connect to database view',
'Source collections': 'Source collections',
'Field source': 'Field source',
Preview: 'Preview',
'Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.':
'Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.',
Edit: 'Edit',
'Edit collection': 'Edit collection',
'Configure fields': 'Configure fields',
'Configure columns': 'Configure columns',
'Edit field': 'Edit field',
Override: 'Override',
'Override field': 'Override field',
'Configure fields of {{title}}': 'Configure fields of {{title}}',
'Association fields filter': 'Association fields filter',
'PK & FK fields': 'PK & FK fields',
'Association fields': 'Association fields',
'Choices fields': 'Choices fields',
'System fields': 'System fields',
'General fields': 'General fields',
'Inherited fields': 'Inherited fields',
'Parent collection fields': 'Parent collection fields',
Basic: 'Basic',
'Single line text': 'Single line text',
'Long text': 'Long text',
Phone: 'Phone',
Email: 'Email',
Number: 'Number',
Integer: 'Integer',
Percent: 'Percent',
Password: 'Password',
'Advanced type': 'Advanced',
Formula: 'Formula',
'Formula description': 'Compute a value in each record based on other fields in the same record.',
Choices: 'Choices',
Checkbox: 'Checkbox',
'Single select': 'Single select',
'Multiple select': 'Multiple select',
'Radio group': 'Radio group',
'Checkbox group': 'Checkbox group',
'China region': 'China region',
'Date & Time': 'Date & Time',
Datetime: 'Datetime',
Relation: 'Relation',
'Link to': 'Link to',
'Link to description':
'Used to create collection relationships quickly and compatible with most common scenarios. Suitable for non-developer use. When present as a field, it is a drop-down selection used to select records from the target collection. Once created, it will simultaneously generate the associated fields of the current collection in the target collection.',
'Sub-table': 'Sub-table',
'Sub-details': 'Sub-details',
'Sub-form(Popover)': 'Sub-form(Popover)',
'System info': 'System info',
'Created at': 'Created at',
'Last updated at': 'Last updated at',
'Created by': 'Created by',
'Last updated by': 'Last updated by',
'Add field': 'Add field',
'Field display name': 'Field display name',
'Field type': 'Field type',
'Field interface': 'Field interface',
'Date format': 'Date format',
'Year/Month/Day': 'Year/Month/Day',
'Year-Month-Day': 'Year-Month-Day',
'Day/Month/Year': 'Day/Month/Year',
'Show time': 'Show time',
'Time format': 'Time format',
'12 hour': '12 hour',
'24 hour': '24 hour',
'Relationship type': 'Relationship type',
'Inverse relationship type': 'Inverse relationship type',
'Source collection': 'Source collection',
'Source key': 'Source key',
'Target collection': 'Target collection',
'Through collection': 'Through collection',
'Target key': 'Target key',
'Foreign key': 'Foreign key',
'One to one': 'One to one',
'One to many': 'One to many',
'Many to one': 'Many to one',
'Many to many': 'Many to many',
'Foreign key 1': 'Foreign key 1',
'Foreign key 2': 'Foreign key 2',
'One to one description': 'Used to create one-to-one relationships. For example, a user has a profile.',
'One to many description':
'Used to create a one-to-many relationship. For example, a country will have many cities and a city can only be in one country. When present as a field, it is a sub-table that displays the records of the associated collection. When created, a Many-to-one field is automatically generated in the associated collection.',
'Many to one description':
'Used to create many-to-one relationships. For example, a city can belong to only one country and a country can have many cities. When present as a field, it is a drop-down selection used to select record from the associated collection. Once created, a One-to-many field is automatically generated in the associated collection.',
'Many to many description':
'Used to create many-to-many relationships. For example, a student will have many teachers and a teacher will have many students. When present as a field, it is a drop-down selection used to select records from the associated collection.',
'Generated automatically if left blank': 'Generated automatically if left blank',
'Display association fields': 'Display association fields',
'Display field title': 'Display field title',
'Field component': 'Field component',
'Allow multiple': 'Allow multiple',
'Quick upload': 'Quick upload',
'Select file': 'Select file',
Subtable: 'Sub-table',
'Sub-form': 'Sub-form',
'Field mode': 'Field mode',
'Allow add new data': 'Allow add new data',
'Record picker': 'Record picker',
'Toggles the subfield mode': 'Toggles the subfield mode',
'Selector mode': 'Selector mode',
'Subtable mode': 'Sub-table mode',
'Subform mode': 'Sub-form mode',
'Edit block title': 'Edit block title',
'Block title': 'Block title',
Pattern: 'Pattern',
Operator: 'Operator',
Editable: 'Editable',
Readonly: 'Readonly',
'Easy-reading': 'Easy-reading',
'Add filter': 'Add filter',
'Add filter group': 'Add filter group',
Comparision: 'Comparision',
is: 'is',
'is not': 'is not',
contains: 'contains',
'does not contain': 'does not contain',
'starts with': 'starts with',
'not starts with': 'not starts with',
'ends with': 'ends with',
'not ends with': 'not ends with',
'is empty': 'is empty',
'is not empty': 'is not empty',
'Edit chart': 'Edit chart',
'Add text': 'Add text',
'Filterable fields': 'Filterable fields',
'Edit button': 'Edit button',
Hide: 'Hide',
'Enable actions': 'Enable actions',
Import: 'Import',
Export: 'Export',
Customize: 'Customize',
Custom: 'Custom',
Function: 'Function',
'Popup form': 'Popup form',
'Flexible popup': 'Flexible popup',
'Configure actions': 'Configure actions',
'Display order number': 'Display order number',
'Enable drag and drop sorting': 'Enable drag and drop sorting',
'Triggered when the row is clicked': 'Triggered when the row is clicked',
'Add tab': 'Add tab',
'Disable tabs': 'Disable tabs',
Details: 'Details',
'Edit tab': 'Edit tab',
'Relationship blocks': 'Relationship blocks',
'Select record': 'Select record',
'Display name': 'Display name',
'Select icon': 'Select icon',
'Custom column name': 'Custom column name',
'Edit description': 'Edit description',
Required: 'Required',
Unique: 'Unique',
'Label field': 'Label field',
'Default is the ID field': 'Default is the ID field',
'Set default sorting rules': 'Set default sorting rules',
'Set validation rules': 'Set validation rules',
'Max length': 'Max length',
'Min length': 'Min length',
Maximum: 'Maximum',
Minimum: 'Minimum',
'Max length must greater than min length': 'Max length must greater than min length',
'Min length must less than max length': 'Min length must less than max length',
'Maximum must greater than minimum': 'Maximum must greater than minimum',
'Minimum must less than maximum': 'Minimum must less than maximum',
'Validation rule': 'Validation rule',
'Add validation rule': 'Add validation rule',
Format: 'Format',
'Regular expression': 'Pattern',
'Error message': 'Error message',
Length: 'Length',
'The field value cannot be greater than ': 'The field value cannot be greater than ',
'The field value cannot be less than ': 'The field value cannot be less than ',
'The field value is not an integer number': 'The field value is not an integer number',
'Set default value': 'Set default value',
'Default value': 'Default value',
'is before': 'is before',
'is after': 'is after',
'is on or after': 'is on or after',
'is on or before': 'is on or before',
'is between': 'is between',
Upload: 'Upload',
'Select level': 'Select level',
Province: 'Province',
City: 'City',
Area: 'Area',
Street: 'Street',
Village: 'Village',
'Must select to the last level': 'Must select to the last level',
'Move {{title}} to': 'Move {{title}} to',
'Target position': 'Target position',
After: 'After',
Before: 'Before',
'Add {{type}} before "{{title}}"': 'Add {{type}} before "{{title}}"',
'Add {{type}} after "{{title}}"': 'Add {{type}} after "{{title}}"',
'Add {{type}} in "{{title}}"': 'Add {{type}} in "{{title}}"',
'Original name': 'Original name',
'Custom name': 'Custom name',
'Custom Title': 'Custom Title',
Options: 'Options',
'Option value': 'Option value',
'Option label': 'Option label',
Color: 'Color',
'Add option': 'Add option',
'Related collection': 'Related collection',
'Allow linking to multiple records': 'Allow linking to multiple records',
'Allow uploading multiple files': 'Allow uploading multiple files',
'Configure calendar': 'Configure calendar',
'Title field': 'Title field',
'Custom title': 'Custom title',
Daily: 'Daily',
Weekly: 'Weekly',
Monthly: 'Monthly',
Yearly: 'Yearly',
Repeats: 'Repeats',
'Show lunar': 'Show lunar',
'Start date field': 'Start date field',
'End date field': 'End date field',
Navigate: 'Navigate',
Title: 'Title',
Description: 'Description',
'Select view': 'Select view',
Reset: 'Reset',
'Importable fields': 'Importable fields',
'Exportable fields': 'Exportable fields',
'Saved successfully': 'Saved successfully',
Nickname: 'Nickname',
'Sign in': 'Sign in',
'Sign in via account': 'Sign in via account',
'Sign in via phone': 'Sign in via phone',
'Create an account': 'Create an account',
'Sign up': 'Sign up',
'Confirm password': 'Confirm password',
'Log in with an existing account': 'Log in with an existing account',
'Signed up successfully. It will jump to the login page.': 'Signed up successfully. It will jump to the login page.',
'Password mismatch': 'Password mismatch',
Users: 'Users',
'Verification code': 'Verification code',
'Send code': 'Send code',
'Retry after {{count}} seconds': 'Retry after {{count}} seconds',
Roles: 'Roles',
'Add role': 'Add role',
'Role name': 'Role name',
Configure: 'Configure',
'Configure permissions': 'Configure permissions',
'Edit role': 'Edit role',
'Action permissions': 'Action permissions',
'Menu permissions': 'Menu permissions',
'Menu item name': 'Menu item name',
'Allow access': 'Allow access',
'Action name': 'Action name',
'Allow action': 'Allow action',
'Action scope': 'Action scope',
'Operate on new data': 'Operate on new data',
'Operate on existing data': 'Operate on existing data',
Yes: 'Yes',
No: 'No',
Red: 'Red',
Magenta: 'Magenta',
Volcano: 'Volcano',
Orange: 'Orange',
Gold: 'Gold',
Lime: 'Lime',
Green: 'Green',
Cyan: 'Cyan',
Blue: 'Blue',
'Geek blue': 'Geek blue',
Purple: 'Purple',
Default: 'Default',
'Add card': 'Add card',
'edit title': 'edit title',
'Turn pages': 'Turn pages',
Others: 'Others',
'Save as template': 'Save as template',
'Save as block template': 'Save as block template',
'Block templates': 'Block templates',
'Convert reference to duplicate': 'Convert reference to duplicate',
'Template name': 'Template name',
'Block type': 'Block type',
'No blocks to connect': 'No blocks to connect',
'Action column': 'Action column',
'Records per page': 'Records per page',
'(Fields only)': '(Fields only)',
'Button title': 'Button title',
'Button icon': 'Button icon',
'Submitted successfully': 'Submitted successfully',
'Operation succeeded': 'Operation succeeded',
'Operation failed': 'Operation failed',
'Open mode': 'Open mode',
'Popup size': 'Popup size',
Small: 'Small',
Middle: 'Middle',
Large: 'Large',
'Menu item title': 'Menu item title',
'Menu item icon': 'Menu item icon',
Target: 'Target',
Position: 'Position',
'Insert before': 'Insert before',
'Insert after': 'Insert after',
'UI Editor': 'UI Editor',
ASC: 'ASC',
DESC: 'DESC',
'Add sort field': 'Add sort field',
ID: 'ID',
'Identifier for program usage. Support letters, numbers and underscores, must start with an letter.':
'Identifier for program usage. Support letters, numbers and underscores, must start with an letter.',
Drawer: 'Drawer',
Dialog: 'Dialog',
'Delete action': 'Delete action',
'Custom column title': 'Custom column title',
'Column title': 'column title',
"Original title: ": "Original title: ",
"Delete table column": "Delete table column",
"Skip required validation": "Skip required validation",
"Form values": "Form values",
"Fields values": "Fields values",
'Original title: ': 'Original title: ',
'Delete table column': 'Delete table column',
'Skip required validation': 'Skip required validation',
'Form values': 'Form values',
'Fields values': 'Fields values',
'The field has been deleted': 'The field has been deleted',
"When submitting the following fields, the saved values are": "When submitting the following fields, the saved values are",
"After successful submission": "After successful submission",
"Then": "Then",
"Stay on current page": "Stay on current page",
"Redirect to": "Redirect to",
"Save action": "Save action",
"Exists": "Exists",
"Add condition": "Add condition",
"Add condition group": "Add condition group",
"exists": "exists",
"not exists": "not exists",
"=": "=",
"≠": "≠",
">": ">",
"≥": "≥",
"<": "<",
"≤": "≤",
"Role UID": "Role UID",
"Precision": "Precision",
"Formula mode": "Formula mode",
"Expression": "Expression",
"Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, ( ) to calculate, input @ to open field variables.",
"Formula error.": "Formula error.",
"Rich Text": "Rich Text",
"Junction collection": "Junction collection",
"Leave it blank, unless you need a custom intermediate table": "Leave it blank, unless you need a custom intermediate table",
"Fields": "Fields",
"Edit field title": "Edit field title",
"Field title": "Field title",
"Original field title: ": "Original field title: ",
"Edit tooltip": "Edit tooltip",
"Delete field": "Delete field",
"Select collection": "Select collection",
"Blank block": "Blank block",
"Duplicate template": "Duplicate template",
"Reference template": "Reference template",
"Create calendar block": "Create calendar block",
"Create kanban block": "Create kanban block",
"Grouping field": "Grouping field",
"Single select and radio fields can be used as the grouping field": "Single select and radio fields can be used as the grouping field",
"Tab name": "Tab name",
"Current record blocks": "Current record blocks",
"Popup message": "Popup message",
"Delete role": "Delete role",
"Role display name": "Role display name",
"Default role": "Default role",
"All collections use general action permissions by default; permission configured individually will override the default one.": "All collections use general action permissions by default; permission configured individually will override the default one.",
"Allows configuration of the whole system, including UI, collections, permissions, etc.": "Allows configuration of the whole system, including UI, collections, permissions, etc.",
"New menu items are allowed to be accessed by default.": "New menu items are allowed to be accessed by default.",
"Global permissions": "Global permissions",
"General permissions": "General permissions",
"Global action permissions": "Global action permissions",
"General action permissions": "General action permissions",
"Plugin settings permissions": "Plugin settings permissions",
"Allow to desgin pages": "Allow to desgin pages",
"Allow to manage plugins": "Allow to manage plugins",
"Allow to configure plugins": "Allow to configure plugins",
"Allows to configure interface": "Allows to configure interface",
"Allows to install, activate, disable plugins": "Allows to install, activate, disable plugins",
"Allows to configure plugins": "Allows to configure plugins",
"Action display name": "Action display name",
"Allow": "Allow",
"Data scope": "Data scope",
"Action on new records": "Action on new records",
"Action on existing records": "Action on existing records",
"All records": "All records",
"Own records": "Own records",
"Permission policy": "Permission policy",
"Individual": "Individual",
"General": "General",
"Accessible": "Accessible",
"Configure permission": "Configure permission",
"Action permission": "Action permission",
"Field permission": "Field permission",
"Scope name": "Scope name",
"Unsaved changes": "Unsaved changes",
'When submitting the following fields, the saved values are':
'When submitting the following fields, the saved values are',
'After successful submission': 'After successful submission',
Then: 'Then',
'Stay on current page': 'Stay on current page',
'Redirect to': 'Redirect to',
'Save action': 'Save action',
Exists: 'Exists',
'Add condition': 'Add condition',
'Add condition group': 'Add condition group',
exists: 'exists',
'not exists': 'not exists',
'=': '=',
'≠': '≠',
'>': '>',
'≥': '≥',
'<': '<',
'≤': '≤',
'Role UID': 'Role UID',
Precision: 'Precision',
'Formula mode': 'Formula mode',
Expression: 'Expression',
'Input +, -, *, /, ( ) to calculate, input @ to open field variables.':
'Input +, -, *, /, ( ) to calculate, input @ to open field variables.',
'Formula error.': 'Formula error.',
'Rich Text': 'Rich Text',
'Junction collection': 'Junction collection',
'Leave it blank, unless you need a custom intermediate table':
'Leave it blank, unless you need a custom intermediate table',
Fields: 'Fields',
'Edit field title': 'Edit field title',
'Field title': 'Field title',
'Original field title: ': 'Original field title: ',
'Edit tooltip': 'Edit tooltip',
'Delete field': 'Delete field',
'Select collection': 'Select collection',
'Blank block': 'Blank block',
'Duplicate template': 'Duplicate template',
'Reference template': 'Reference template',
'Create calendar block': 'Create calendar block',
'Create kanban block': 'Create kanban block',
'Grouping field': 'Grouping field',
'Single select and radio fields can be used as the grouping field':
'Single select and radio fields can be used as the grouping field',
'Tab name': 'Tab name',
'Current record blocks': 'Current record blocks',
'Popup message': 'Popup message',
'Delete role': 'Delete role',
'Role display name': 'Role display name',
'Default role': 'Default role',
'All collections use general action permissions by default; permission configured individually will override the default one.':
'All collections use general action permissions by default; permission configured individually will override the default one.',
'Allows configuration of the whole system, including UI, collections, permissions, etc.':
'Allows configuration of the whole system, including UI, collections, permissions, etc.',
'New menu items are allowed to be accessed by default.': 'New menu items are allowed to be accessed by default.',
'Global permissions': 'Global permissions',
'General permissions': 'General permissions',
'Global action permissions': 'Global action permissions',
'General action permissions': 'General action permissions',
'Plugin settings permissions': 'Plugin settings permissions',
'Allow to desgin pages': 'Allow to desgin pages',
'Allow to manage plugins': 'Allow to manage plugins',
'Allow to configure plugins': 'Allow to configure plugins',
'Allows to configure interface': 'Allows to configure interface',
'Allows to install, activate, disable plugins': 'Allows to install, activate, disable plugins',
'Allows to configure plugins': 'Allows to configure plugins',
'Action display name': 'Action display name',
Allow: 'Allow',
'Data scope': 'Data scope',
'Action on new records': 'Action on new records',
'Action on existing records': 'Action on existing records',
'All records': 'All records',
'Own records': 'Own records',
'Permission policy': 'Permission policy',
Individual: 'Individual',
General: 'General',
Accessible: 'Accessible',
'Configure permission': 'Configure permission',
'Action permission': 'Action permission',
'Field permission': 'Field permission',
'Scope name': 'Scope name',
'Unsaved changes': 'Unsaved changes',
"Are you sure you don't want to save?": "Are you sure you don't want to save?",
"Dragging": "Dragging",
"Popup": "Popup",
@ -765,4 +778,7 @@ export default {
"Select all":"Select all",
"Restart": "Restart",
"Restart application": "Restart application",
Execute: 'Execute',
'Please use a valid SELECT or WITH AS statement': 'Please use a valid SELECT or WITH AS statement',
'Please confirm the SQL statement first': 'Please confirm the SQL statement first',
};

View File

@ -222,6 +222,7 @@ export default {
'Records can be sorted': '可以对行记录进行排序',
'Calendar collection': '日历数据表',
'General collection': '普通数据表',
'SQL collection': 'SQL数据表',
'Connect to database view': '连接数据库视图',
'Source collections': '来源数据表',
'Field source': '来源字段',
@ -709,50 +710,52 @@ export default {
'Plugin manager': '插件管理器',
Local: '本地',
'Built-in': '内置',
'Marketplace': '插件市场',
"Add plugin": "新增插件",
"Upgrade": "可供更新",
"Plugin dependencies check failed": "插件依赖检查失败",
"Remove": "移除",
"Docs": "文档",
"More details": "更多详情",
"Upload new version": "上传新版",
"Official plugin": "官方插件",
"Version": "版本",
"Npm package": "Npm 包",
"Upload plugin": "上传插件",
"Npm package name": "Npm 包名",
"Add type": "新增方式",
"Plugin source": "插件来源",
"Changelog": "更新日志",
"Dependencies check": "依赖检查",
"Update plugin": "更新插件",
"Installing": "安装中",
"The deletion was successful.": "删除成功",
"Plugin Zip File": "插件压缩包",
"Compressed file url": "压缩包地址",
"Last updated": "最后更新",
"PackageName": "包名",
"DisplayName": "显示名称",
"Readme": "说明文档",
"Dependencies compatibility check": "依赖兼容性检查",
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "插件兼容性检查失败,你应该修改依赖版本以满足版本要求。",
"Version range": "版本范围",
"Plugin's version": "插件的版本",
"Result": "结果",
"No CHANGELOG.md file": "没有 CHANGELOG.md 日志",
"No README.md file": "没有 README.md 文件",
"Homepage": "主页",
'Drag and drop the file here or click to upload, file size should not exceed 30M': '将文件拖放到此处或单击上传,文件大小不应超过 30M',
"Dependencies check failed, can't enable.": "依赖检查失败,无法启用。",
"Plugin starting...": "插件启动中...",
"Plugin stopping...": "插件停止中...",
"Are you sure to delete this plugin?": "确定要删除此插件吗?",
"re-download file": "重新下载文件",
"Not enabled": "未启用",
"Search plugin": "搜索插件",
"Author": "作者",
"Plugin loading failed. Please check the server logs.": "插件加载失败,请检查服务器日志。",
Marketplace: '插件市场',
'Add plugin': '新增插件',
Upgrade: '可供更新',
'Plugin dependencies check failed': '插件依赖检查失败',
Remove: '移除',
Docs: '文档',
'More details': '更多详情',
'Upload new version': '上传新版',
'Official plugin': '官方插件',
Version: '版本',
'Npm package': 'Npm 包',
'Upload plugin': '上传插件',
'Npm package name': 'Npm 包名',
'Add type': '新增方式',
'Plugin source': '插件来源',
Changelog: '更新日志',
'Dependencies check': '依赖检查',
'Update plugin': '更新插件',
Installing: '安装中',
'The deletion was successful.': '删除成功',
'Plugin Zip File': '插件压缩包',
'Compressed file url': '压缩包地址',
'Last updated': '最后更新',
PackageName: '包名',
DisplayName: '显示名称',
Readme: '说明文档',
'Dependencies compatibility check': '依赖兼容性检查',
'Plugin dependencies check failed, you should change the dependent version to meet the version requirements.':
'插件兼容性检查失败,你应该修改依赖版本以满足版本要求。',
'Version range': '版本范围',
"Plugin's version": '插件的版本',
Result: '结果',
'No CHANGELOG.md file': '没有 CHANGELOG.md 日志',
'No README.md file': '没有 README.md 文件',
Homepage: '主页',
'Drag and drop the file here or click to upload, file size should not exceed 30M':
'将文件拖放到此处或单击上传,文件大小不应超过 30M',
"Dependencies check failed, can't enable.": '依赖检查失败,无法启用。',
'Plugin starting...': '插件启动中...',
'Plugin stopping...': '插件停止中...',
'Are you sure to delete this plugin?': '确定要删除此插件吗?',
're-download file': '重新下载文件',
'Not enabled': '未启用',
'Search plugin': '搜索插件',
Author: '作者',
'Plugin loading failed. Please check the server logs.': '插件加载失败,请检查服务器日志。',
'Coming soon...': '敬请期待...',
'All plugin settings': '所有插件配置',
Bookmark: '书签',
@ -863,11 +866,13 @@ export default {
'Date display format': '日期显示格式',
'Assign data scope for the template': '为模板指定数据范围',
'Table selected records': '表格中选中的记录',
Tag: '标签',
'Tag color field': '标签颜色字段',
'Sync successfully': '同步成功',
'Sync from form fields': '同步表单字段',
'Select all': '全选',
'Determine whether a record exists by the following fields': '通过以下字段判断记录是否存在',
Execute: '执行',
'Please use a valid SELECT or WITH AS statement': '请使用有效的 SELECT 或 WITH AS 语句',
'Please confirm the SQL statement first': '请先确认 SQL 语句',
};

View File

@ -76,7 +76,7 @@ export const CalendarActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
],

View File

@ -25,7 +25,7 @@ export const CalendarFormActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -38,7 +38,7 @@ export const CalendarFormActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -51,7 +51,7 @@ export const CalendarFormActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -146,7 +146,7 @@ export const CalendarFormActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -171,9 +171,9 @@ export const CalendarFormActionInitializers = {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
visible: () => {
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
],

View File

@ -31,9 +31,9 @@ export const GridCardActionInitializers = {
skipScopeCheck: true,
},
},
visible: () => {
visible: function useVisible() {
const collection = useCollection();
return (collection.template !== 'view' && collection.template !== 'file') || collection?.writableView;
return !['view', 'file', 'sql'].includes(collection.template) || collection?.writableView;
},
},
{
@ -55,6 +55,10 @@ export const GridCardActionInitializers = {
skipScopeCheck: true,
},
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'sql';
},
},
{
type: 'item',
@ -163,9 +167,9 @@ export const GridCardItemActionInitializers = {
'x-decorator': 'ACLActionProvider',
'x-align': 'left',
},
visible: () => {
visible: function useVisible() {
const collection = useCollection();
return (collection as any).template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -178,6 +182,10 @@ export const GridCardItemActionInitializers = {
'x-decorator': 'ACLActionProvider',
'x-align': 'left',
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'sql';
},
},
],
},
@ -261,9 +269,9 @@ export const GridCardItemActionInitializers = {
useProps: '{{ useCustomizeUpdateActionProps }}',
},
},
visible: () => {
visible: function useVisible() {
const collection = useCollection();
return (collection as any).template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -287,9 +295,9 @@ export const GridCardItemActionInitializers = {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
visible: () => {
visible: function useVisible() {
const collection = useCollection();
return (collection as any).template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
],

View File

@ -33,7 +33,11 @@ export const ListActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'file';
return (
(collection.template !== 'view' || collection?.writableView) &&
collection.template !== 'file' &&
collection.template !== 'sql'
);
},
},
{
@ -55,6 +59,10 @@ export const ListActionInitializers = {
skipScopeCheck: true,
},
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'sql';
},
},
{
type: 'item',
@ -165,7 +173,7 @@ export const ListItemActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return (collection as any).template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -178,6 +186,10 @@ export const ListItemActionInitializers = {
'x-decorator': 'ACLActionProvider',
'x-align': 'left',
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'sql';
},
},
],
},
@ -263,7 +275,7 @@ export const ListItemActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return (collection as any).template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -289,7 +301,7 @@ export const ListItemActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return (collection as any).template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
],

View File

@ -2,7 +2,7 @@ import { useCollection } from '../..';
const useVisibleCollection = () => {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
};
// 表单的操作配置
export const ReadPrettyFormActionInitializers = {

View File

@ -17,10 +17,11 @@ const recursiveParent = (schema: Schema) => {
const useRelationFields = () => {
const fieldSchema = useFieldSchema();
const { getCollectionFields } = useCollectionManager();
const collection = useCollection();
let fields = [];
if (fieldSchema['x-initializer']) {
fields = useCollection().fields;
fields = collection.fields;
} else {
const collection = recursiveParent(fieldSchema.parent);
if (collection) {
@ -184,7 +185,18 @@ export const RecordBlockInitializers = (props: any) => {
const hasFormChildCollection = formChildrenCollections?.length > 0;
const detailChildrenCollections = getChildrenCollections(collection.name, true);
const hasDetailChildCollection = detailChildrenCollections?.length > 0;
const modifyFlag = (collection as any).template !== 'view' || collection?.writableView;
const modifyFlag = (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
const detailChildren = useDetailCollections({
...props,
childrenCollections: detailChildrenCollections,
collection,
});
const formChildren = useFormCollections({
...props,
childrenCollections: formChildrenCollections,
collection,
});
return (
<SchemaInitializer.Button
wrap={gridRowColWrap}
@ -202,11 +214,7 @@ export const RecordBlockInitializers = (props: any) => {
key: 'details',
type: 'subMenu',
title: '{{t("Details")}}',
children: useDetailCollections({
...props,
childrenCollections: detailChildrenCollections,
collection,
}),
children: detailChildren,
}
: {
key: 'details',
@ -220,11 +228,7 @@ export const RecordBlockInitializers = (props: any) => {
key: 'form',
type: 'subMenu',
title: '{{t("Form")}}',
children: useFormCollections({
...props,
childrenCollections: formChildrenCollections,
collection,
}),
children: formChildren,
}
: modifyFlag && {
key: 'form',

View File

@ -101,7 +101,7 @@ export const TableActionColumnInitializers = (props: any) => {
'x-decorator': 'ACLActionProvider',
},
visible: () => {
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
@ -115,7 +115,7 @@ export const TableActionColumnInitializers = (props: any) => {
'x-decorator': 'ACLActionProvider',
},
visible: () => {
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
collection.tree &&
@ -139,7 +139,7 @@ export const TableActionColumnInitializers = (props: any) => {
'x-decorator': 'ACLActionProvider',
},
visible: () => {
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
],
@ -225,7 +225,7 @@ export const TableActionColumnInitializers = (props: any) => {
},
},
visible: () => {
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
@ -250,7 +250,7 @@ export const TableActionColumnInitializers = (props: any) => {
},
},
visible: () => {
return collection.template !== 'view' || collection?.writableView;
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
],

View File

@ -34,7 +34,7 @@ export const TableActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return (collection.template !== 'view' && collection.template !== 'file') || collection?.writableView;
return !['view', 'file', 'sql'].includes(collection.template) || collection?.writableView;
},
},
{
@ -47,7 +47,7 @@ export const TableActionInitializers = {
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return !['view', 'sql'].includes(collection.template) || collection?.writableView;
},
},
{
@ -78,7 +78,7 @@ export const TableActionInitializers = {
type: 'divider',
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return !['view', 'sql'].includes(collection.template) || collection?.writableView;
},
},
// {
@ -172,7 +172,7 @@ export const TableActionInitializers = {
],
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'view' || collection?.writableView;
return !['view', 'sql'].includes(collection.template) || collection?.writableView;
},
},
],

View File

@ -18,7 +18,9 @@ export const DetailsBlockInitializer = (props) => {
collection: item.name,
rowKey: collection.filterTargetKey || 'id',
actionInitializers:
(collection.template !== 'view' || collection?.writableView) && 'DetailsActionInitializers',
(collection.template !== 'view' || collection?.writableView) &&
collection.template !== 'sql' &&
'DetailsActionInitializers',
});
insert(schema);
}}

View File

@ -862,7 +862,10 @@ export const useCollectionDataSourceItems = (componentName) => {
const fields = getCollectionFields(item.name);
if (item.autoGenId === false && !fields.find((v) => v.primaryKey)) {
return false;
} else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view' && !item.writableView) {
} else if (
['Kanban', 'FormItem'].includes(componentName) &&
((item.template === 'view' && !item.writableView) || item.template === 'sql')
) {
return false;
} else if (item.template === 'file' && ['Kanban', 'FormItem', 'Calendar'].includes(componentName)) {
return false;

View File

@ -0,0 +1,63 @@
import Database from '../../database';
import { mockDatabase } from '../../mock-database';
import { SQLModel } from '../../sql-collection/sql-model';
describe('infer fields', () => {
let db: Database;
beforeAll(async () => {
db = mockDatabase({ tablePrefix: '' });
await db.clean({ drop: true });
db.collection({
name: 'users',
schema: 'public',
fields: [
{ name: 'id', type: 'bigInt', interface: 'id' },
{ name: 'nickname', type: 'string', interface: 'input' },
],
});
db.collection({
name: 'roles',
schema: 'public',
fields: [
{ name: 'id', type: 'bigInt', interface: 'id' },
{ name: 'title', type: 'string', interface: 'input' },
{ name: 'name', type: 'string', interface: 'uid' },
],
});
db.collection({
name: 'roles_users',
schema: 'public',
fields: [
{ name: 'id', type: 'bigInt', interface: 'id' },
{ name: 'userId', type: 'bigInt', interface: 'id' },
{ name: 'roleName', type: 'bigInt', interface: 'id' },
],
});
await db.sync();
});
afterAll(async () => {
await db.close();
});
it('should infer fields', async () => {
const model = class extends SQLModel {};
model.init(null, {
modelName: 'roles_users',
tableName: 'roles_users',
sequelize: db.sequelize,
});
model.database = db;
model.sql = `select u.id as uid, u.nickname, r.title, r.name
from users u left join roles_users ru on ru.user_id = u.id
left join roles r on ru.role_name=r.name`;
expect(model.inferFields()).toMatchObject({
uid: { type: 'bigInt', source: 'users.id' },
nickname: { type: 'string', source: 'users.nickname' },
title: { type: 'string', source: 'roles.title' },
name: { type: 'string', source: 'roles.name' },
});
});
});

View File

@ -0,0 +1,71 @@
import { SQLModel } from '../../sql-collection/sql-model';
import { Sequelize } from 'sequelize';
describe('select query', () => {
const model = class extends SQLModel {};
model.init(null, {
modelName: 'users',
tableName: 'users',
sequelize: new Sequelize({
dialect: 'postgres',
}),
});
model.sql = 'SELECT * FROM "users"';
model.collection = {
fields: new Map(
Object.entries({
id: {},
name: {},
}),
),
} as any;
const queryGenerator = model.queryInterface.queryGenerator as any;
test('plain sql', () => {
const query = queryGenerator.selectQuery('users', {}, model);
expect(query).toBe('SELECT * FROM "users";');
});
test('attributes', () => {
const query = queryGenerator.selectQuery('users', { attributes: ['id', 'name'] }, model);
expect(query).toBe('SELECT "id", "name" FROM (SELECT * FROM "users") AS "users";');
});
test('where', () => {
const query = queryGenerator.selectQuery('users', { where: { id: 1 } }, model);
expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" WHERE "users"."id" = 1;');
});
test('group', () => {
const query = queryGenerator.selectQuery('users', { group: 'id' }, model);
expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" GROUP BY "id";');
});
test('order', () => {
const query = queryGenerator.selectQuery('users', { order: ['id'] }, model);
expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" ORDER BY "users"."id";');
});
test('limit, offset', () => {
const query = queryGenerator.selectQuery('users', { limit: 1, offset: 0 }, model);
expect(query).toBe('SELECT * FROM (SELECT * FROM "users") AS "users" LIMIT 1 OFFSET 0;');
});
test('complex sql', () => {
const query = queryGenerator.selectQuery(
'users',
{
attributes: ['id', 'name'],
where: { id: 1 },
group: 'id',
order: ['id'],
limit: 1,
offset: 0,
},
model,
);
expect(query).toBe(
'SELECT "id", "name" FROM (SELECT * FROM "users") AS "users" WHERE "users"."id" = 1 GROUP BY "id" ORDER BY "users"."id" LIMIT 1 OFFSET 0;',
);
});
});

View File

@ -10,4 +10,16 @@ describe('sql parser', () => {
expect(firstColumn['expr']['table']).toEqual('users');
expect(firstColumn['expr']['column']).toEqual('id');
});
// it('should parse complex sql', function () {
// const sql = `select u.id, u.nickname, r.title, r.name from users u left join roles_users ru on ru.user_id = u.id left join roles r on ru.role_name=r.name`;
// const { ast } = sqlParser.parse(sql);
// console.log(JSON.stringify(ast, null, 2));
// });
//
// it('should parse with subquery', function () {
// const sql = `with t as (select * from users), v as (select * from roles) select * from t, v where t.id = v.id;`;
// const { ast } = sqlParser.parse(sql);
// console.log(JSON.stringify(ast, null, 2));
// });
});

View File

@ -70,6 +70,7 @@ import {
import { patchSequelizeQueryInterface, snakeCase } from './utils';
import { BaseValueParser, registerFieldValueParsers } from './value-parsers';
import { ViewCollection } from './view-collection';
import { SqlCollection } from './sql-collection/sql-collection';
export type MergeOptions = merge.Options;
@ -358,6 +359,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
if (collection.options.schema) {
collection.model._schema = collection.options.schema;
}
if (collection.options.sql) {
collection.modelInit();
}
});
this.on('beforeDefineCollection', (options) => {
@ -451,6 +455,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
return ViewCollection;
}
if (options.sql) {
return SqlCollection;
}
return Collection;
})();

View File

@ -41,4 +41,5 @@ export { snakeCase } from './utils';
export * from './value-parsers';
export * from './view-collection';
export * from './view/view-inference';
export * from './sql-collection';
export * from './helpers';

View File

@ -0,0 +1,2 @@
export * from './sql-model';
export * from './sql-collection';

View File

@ -0,0 +1,69 @@
import { GroupOption, Order, ProjectionAlias, WhereOptions } from 'sequelize';
import { SQLModel } from './sql-model';
import { lodash } from '@nocobase/utils';
import { Collection } from '../collection';
export function selectQuery(
tableName: string,
options: {
attributes?: (string | ProjectionAlias)[];
where?: WhereOptions;
order?: Order;
group?: GroupOption;
limit?: number;
offset?: number;
},
model: SQLModel,
) {
options = options || {};
if (lodash.isEmpty(options)) {
return `${model.sql};`;
}
const queryItems = [];
let attributes = options.attributes && options.attributes.slice();
if (attributes) {
const fields = Array.from((model.collection as Collection)?.fields.keys() || []);
attributes = attributes.filter((attr: any) => attr === '*' || typeof attr !== 'string' || fields.includes(attr));
}
attributes = this.escapeAttributes(attributes, { model });
attributes = attributes || ['*'];
// Add WHERE to sub or main query
if (Object.prototype.hasOwnProperty.call(options, 'where')) {
options.where = this.getWhereConditions(options.where, tableName, model, options);
if (options.where) {
queryItems.push(` WHERE ${options.where}`);
}
}
// Add GROUP BY to sub or main query
if (options.group) {
options.group = Array.isArray(options.group)
? options.group.map((t) => this.aliasGrouping(t, model, tableName, options)).join(', ')
: this.aliasGrouping(options.group, model, tableName, options);
if (options.group) {
queryItems.push(` GROUP BY ${options.group}`);
}
}
// Add ORDER to sub or main query
if (options.order) {
const orders = this.getQueryOrders(options, model, false);
if (orders.mainQueryOrder.length) {
queryItems.push(` ORDER BY ${orders.mainQueryOrder.join(', ')}`);
}
}
// Add LIMIT, OFFSET to sub or main query
const limitOrder = this.addLimitAndOffset(options, model);
if (limitOrder) {
queryItems.push(limitOrder);
}
const query = `SELECT ${attributes.join(', ')} FROM (${model.sql}) ${this.getAliasToken()} ${this.quoteIdentifier(
model.name,
)}${queryItems.join('')}`;
return `${query};`;
}

View File

@ -0,0 +1,46 @@
import { Collection, CollectionContext, CollectionOptions } from '../collection';
import { SQLModel } from './sql-model';
export class SqlCollection extends Collection {
constructor(options: CollectionOptions, context: CollectionContext) {
options.autoGenId = false;
options.timestamps = false;
options.underscored = false;
super(options, context);
}
isSql() {
return true;
}
public collectionSchema() {
return undefined;
}
modelInit() {
const { autoGenId, sql } = this.options;
const model = class extends SQLModel {};
model.init(null, {
...this.sequelizeModelOptions(),
schema: undefined,
});
if (!autoGenId) {
model.removeAttribute('id');
}
model.sql = sql;
model.database = this.context.database;
model.collection = this;
this.model = new Proxy(model, {
get(target, prop) {
if (prop === '_schema') {
return undefined;
}
return Reflect.get(target, prop);
},
});
}
}

View File

@ -0,0 +1,157 @@
import { Model } from '../model';
import sqlParser from '../sql-parser';
import { selectQuery } from './query-generator';
export class SQLModel extends Model {
static sql: string;
static get queryInterface() {
const queryInterface = this.sequelize.getQueryInterface();
const queryGenerator = queryInterface.queryGenerator as any;
const sqlGenerator = new Proxy(queryGenerator, {
get(target, prop) {
if (prop === 'selectQuery') {
return selectQuery.bind(target);
}
return Reflect.get(target, prop);
},
});
return new Proxy(queryInterface, {
get(target, prop) {
if (prop === 'queryGenerator') {
return sqlGenerator;
}
return Reflect.get(target, prop);
},
});
}
static async sync(): Promise<any> {}
private static parseTablesAndColumns(): {
table: string;
columns: string | { name: string; as: string }[];
}[] {
let { ast } = sqlParser.parse(this.sql);
if (Array.isArray(ast)) {
ast = ast[0];
}
if (ast.with) {
const tables = new Set<string>();
// parse sql includes with clause is not accurate
// the parsed columns seems to be always '*'
// it is supposed to be improved in the future
ast.with.forEach((withItem: { tableList: string[] }) => {
const tableList = withItem.tableList;
tableList.forEach((table) => {
const name = table.split('::')[2]; // "select::null::users"
tables.add(name);
});
});
return Array.from(tables).map((table) => ({ table, columns: '*' }));
}
if (ast.columns === '*') {
return ast.from.map((fromItem: { table: string; as: string }) => ({
table: fromItem.table,
columns: '*',
}));
}
const columns: string[] = ast.columns.reduce(
(
tableMp: { [table: string]: { name: string; as: string }[] },
column: {
as: string;
expr: {
table: string;
column: string;
type: string;
};
},
) => {
if (column.expr.type !== 'column_ref') {
return tableMp;
}
const table = column.expr.table;
const columnAttr = { name: column.expr.column, as: column.as };
if (!tableMp[table]) {
tableMp[table] = [columnAttr];
} else {
tableMp[table].push(columnAttr);
}
return tableMp;
},
{},
);
ast.from.forEach((fromItem: { table: string; as: string }) => {
if (columns[fromItem.as]) {
columns[fromItem.table] = columns[fromItem.as];
columns[fromItem.as] = undefined;
}
});
return Object.entries(columns)
.filter(([_, columns]) => columns)
.map(([table, columns]) => ({ table, columns }));
}
private static getTableNameWithSchema(table: string) {
if (this.database.inDialect('postgres') && !table.includes('.')) {
return `public.${table}`;
}
return table;
}
static inferFields(): {
[field: string]: {
type: string;
source: string;
collection: string;
interface: string;
};
} {
const tables = this.parseTablesAndColumns();
const fields = tables.reduce((fields, { table, columns }) => {
const tableName = this.getTableNameWithSchema(table);
const collection = this.database.tableNameCollectionMap.get(tableName);
if (!collection) {
return fields;
}
const attributes = collection.model.getAttributes();
const sourceFields = {};
if (columns === '*') {
Object.values(attributes).forEach((attribute) => {
const field = collection.getField((attribute as any).fieldName);
if (!field?.options.interface) {
return;
}
sourceFields[field.name] = {
collection: field.collection.name,
type: field.type,
source: `${field.collection.name}.${field.name}`,
interface: field.options.interface,
uiSchema: field.options.uiSchema,
};
});
} else {
(columns as { name: string; as: string }[]).forEach((column) => {
const modelField = Object.values(attributes).find((attribute) => attribute.field === column.name);
if (!modelField) {
return;
}
const field = collection.getField((modelField as any).fieldName);
if (!field?.options.interface) {
return;
}
sourceFields[column.as || column.name] = {
collection: field.collection.name,
type: field.type,
source: `${field.collection.name}.${field.name}`,
interface: field.options.interface,
uiSchema: field.options.uiSchema,
};
});
}
return { ...fields, ...sourceFields };
}, {});
return fields;
}
}

View File

@ -6,6 +6,7 @@ interface LoadOptions extends Transactionable {
// TODO
skipField?: boolean | Array<string>;
skipExist?: boolean;
resetFields?: boolean;
}
export class CollectionModel extends MagicAttributeModel {
@ -14,7 +15,7 @@ export class CollectionModel extends MagicAttributeModel {
}
async load(loadOptions: LoadOptions = {}) {
const { skipExist, skipField, transaction } = loadOptions;
const { skipExist, skipField, resetFields, transaction } = loadOptions;
const name = this.get('name');
let collection: Collection;
@ -23,7 +24,6 @@ export class CollectionModel extends MagicAttributeModel {
...this.get(),
fields: [],
};
if (this.db.hasCollection(name)) {
collection = this.db.getCollection(name);
@ -31,6 +31,10 @@ export class CollectionModel extends MagicAttributeModel {
return collection;
}
if (resetFields) {
collection.resetFields();
}
collection.updateOptions(collectionOptions);
} else {
collection = this.db.collection(collectionOptions);

View File

@ -0,0 +1,96 @@
import { Context, Next } from '@nocobase/actions';
import { SQLModel, SqlCollection } from '@nocobase/database';
import { CollectionModel } from '../models';
const updateCollection = async (ctx: Context, transaction: any) => {
const { filterByTk, values } = ctx.action.params;
const repo = ctx.db.getRepository('collections');
const collection: CollectionModel = await repo.findOne({
filter: {
name: filterByTk,
},
transaction,
});
const existFields = await collection.getFields({ transaction });
const deletedFields = existFields.filter((field: any) => !values.fields?.find((f: any) => f.name === field.name));
for (const field of deletedFields) {
await field.destroy({ transaction });
}
const upRes = await repo.update({
filterByTk,
values,
updateAssociationValues: ['fields'],
transaction,
});
return { collection, upRes };
};
export default {
name: 'sqlCollection',
actions: {
execute: async (ctx: Context, next: Next) => {
let {
values: { sql },
} = ctx.action.params;
sql = sql.trim().split(';').shift();
if (!sql) {
ctx.throw(400, ctx.t('SQL is empty'));
}
if (!/^select/i.test(sql) && !/^with([\s\S]+)select([\s\S]+)/i.test(sql)) {
ctx.throw(400, ctx.t('Only select query allowed'));
}
const tmpCollection = new SqlCollection({ name: 'tmp', sql }, { database: ctx.db });
const model = tmpCollection.model as typeof SQLModel;
// The result is for preview only, add limit clause to avoid too many results
const data = await model.findAll({ attributes: ['*'], limit: 5, raw: true });
let fields: {
[field: string]: {
type: string;
source: string;
collection: string;
interface: string;
};
} = {};
try {
fields = model.inferFields();
} catch (err) {
ctx.logger.warn('resource: sql-collection, action: execute, error: ', err);
fields = {};
}
const sources = Array.from(new Set(Object.values(fields).map((field) => field.collection)));
ctx.body = { data, fields, sources };
await next();
},
setFields: async (ctx: Context, next: Next) => {
const transaction = await ctx.app.db.sequelize.transaction();
try {
const {
upRes: [collection],
} = await updateCollection(ctx, transaction);
await collection.loadFields({
transaction,
});
await transaction.commit();
} catch (e) {
await transaction.rollback();
throw e;
}
await next();
},
update: async (ctx: Context, next: Next) => {
const transaction = await ctx.app.db.sequelize.transaction();
try {
const { upRes } = await updateCollection(ctx, transaction);
const [collection] = upRes;
await (collection as CollectionModel).load({ transaction, resetFields: true });
await transaction.commit();
ctx.body = upRes;
} catch (e) {
await transaction.rollback();
throw e;
}
await next();
},
},
};

View File

@ -17,6 +17,7 @@ import { beforeCreateForViewCollection } from './hooks/beforeCreateForViewCollec
import { CollectionModel, FieldModel } from './models';
import collectionActions from './resourcers/collections';
import viewResourcer from './resourcers/views';
import sqlResourcer from './resourcers/sql';
export class CollectionManagerPlugin extends Plugin {
public schema: string;
@ -51,7 +52,7 @@ export class CollectionManagerPlugin extends Plugin {
this.app.acl.registerSnippet({
name: `pm.${this.name}.collections`,
actions: ['collections:*', 'collections.fields:*', 'dbViews:*', 'collectionCategories:*'],
actions: ['collections:*', 'collections.fields:*', 'dbViews:*', 'collectionCategories:*', 'sqlCollection:*'],
});
this.app.db.on('collections.beforeCreate', async (model) => {
@ -277,6 +278,7 @@ export class CollectionManagerPlugin extends Plugin {
});
this.app.resource(viewResourcer);
this.app.resource(sqlResourcer);
this.app.actions(collectionActions);
const handleFieldSource = (fields) => {

View File

@ -14,6 +14,7 @@ describe('query', () => {
beforeAll(() => {
app = mockServer();
app.db.options.underscored = true;
app.db.collection({
name: 'orders',
fields: [

View File

@ -167,7 +167,7 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => {
const { collection: collectionName, measures, dimensions, orders, filter } = ctx.action.params.values as QueryParams;
const collection = ctx.db.getCollection(collectionName);
const fields = collection.fields;
const underscored = ctx.db.options.underscored;
const underscored = collection.options.underscored;
const models: {
[target: string]: {
type: string;

View File

@ -20,7 +20,11 @@ export const ImportInitializerProvider = (props: any) => {
},
visible: function useVisible() {
const collection = useCollection();
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'file';
return (
(collection.template !== 'view' || collection?.writableView) &&
collection.template !== 'file' &&
collection.template !== 'sql'
);
},
});
return props.children;

View File

@ -1,3 +1,5 @@
import { useCollection } from '@nocobase/client';
// 表格操作配置
export const MapActionInitializers = {
title: "{{t('Configure actions')}}",
@ -29,6 +31,10 @@ export const MapActionInitializers = {
skipScopeCheck: true,
},
},
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'sql';
},
},
{
type: 'item',
@ -42,6 +48,10 @@ export const MapActionInitializers = {
},
{
type: 'divider',
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'sql';
},
},
{
type: 'subMenu',
@ -92,6 +102,10 @@ export const MapActionInitializers = {
},
},
],
visible: function useVisible() {
const collection = useCollection();
return collection.template !== 'sql';
},
},
],
};

View File

@ -23324,7 +23324,7 @@ vite@4.3.1:
vite@^4.4.9:
version "4.4.9"
resolved "https://registry.npmmirror.com/vite/-/vite-4.4.9.tgz#1402423f1a2f8d66fd8d15e351127c7236d29d3d"
resolved "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz#1402423f1a2f8d66fd8d15e351127c7236d29d3d"
integrity sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==
dependencies:
esbuild "^0.18.10"