diff --git a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx
index 325c51130..fa4691d33 100644
--- a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx
+++ b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx
@@ -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,
}}
/>
//
diff --git a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx
index 1ab063e7c..07b76a84e 100644
--- a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx
+++ b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx
@@ -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] : [];
diff --git a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx
index 453365b0d..203bcbb99 100644
--- a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx
+++ b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx
@@ -302,35 +302,37 @@ export const AddFieldAction = (props) => {
};
}, [getInterface, items, record]);
return (
-
-
-
- {children || (
- } type={'primary'}>
- {t('Add field')}
-
- )}
-
-
-
-
+ record.template !== 'sql' && (
+
+
+
+ {children || (
+ } type={'primary'}>
+ {t('Add field')}
+
+ )}
+
+
+
+
+ )
);
};
diff --git a/packages/core/client/src/collection-manager/Configuration/CollectionFields.tsx b/packages/core/client/src/collection-manager/Configuration/CollectionFields.tsx
index 3264c0a72..9b4792e85 100644
--- a/packages/core/client/src/collection-manager/Configuration/CollectionFields.tsx
+++ b/packages/core/client/src/collection-manager/Configuration/CollectionFields.tsx
@@ -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 = () => {
>
+
{
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();
diff --git a/packages/core/client/src/collection-manager/Configuration/SyncSQLFieldsAction.tsx b/packages/core/client/src/collection-manager/Configuration/SyncSQLFieldsAction.tsx
new file mode 100644
index 000000000..4d6b03f2f
--- /dev/null
+++ b/packages/core/client/src/collection-manager/Configuration/SyncSQLFieldsAction.tsx
@@ -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' && (
+
+
+
+ } onClick={(e) => setVisible(true)}>
+ {t('Sync from database')}
+
+ useSyncFromDB(refreshCMList),
+ }}
+ />
+
+
+
+ )
+ );
+};
diff --git a/packages/core/client/src/collection-manager/Configuration/index.tsx b/packages/core/client/src/collection-manager/Configuration/index.tsx
index faec9c4f1..ad184f595 100644
--- a/packages/core/client/src/collection-manager/Configuration/index.tsx
+++ b/packages/core/client/src/collection-manager/Configuration/index.tsx
@@ -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_-]*$/,
diff --git a/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts b/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts
index cbea11e59..6a135986d 100644
--- a/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts
+++ b/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts
@@ -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") }}',
diff --git a/packages/core/client/src/collection-manager/templates/components/sql-collection/FieldsConfigure.tsx b/packages/core/client/src/collection-manager/templates/components/sql-collection/FieldsConfigure.tsx
new file mode 100644
index 000000000..3fa937d6a
--- /dev/null
+++ b/packages/core/client/src/collection-manager/templates/components/sql-collection/FieldsConfigure.tsx
@@ -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 ;
+ }
+ if (!data && !error) {
+ return ;
+ }
+ const err = error as any;
+ if (err) {
+ const errMsg =
+ err?.response?.data?.errors?.map?.((item: { message: string }) => item.message).join('\n') || err.message;
+ return ;
+ }
+
+ 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 (
+ {
+ 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 ? (
+ {compile(getInterface(text)?.title) || text}
+ ) : (
+