mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
feat(data-source-main): reading tables & change field interfaces (#7118)
* fix: test
* refactor: remove group for CollectionFields
* feat: support change field type
* feat: support change interface
* fix: type error
* fix: build error
* fix: handle field source
* fix: test
* fix: test
* Revert "Revert "feat: support reading tables from main data source (#7238)" (…"
This reverts commit c881117c9d.
* fix: disable modify preset fields interface and type when collection is db2cm
* refactor: read and load tables
* fix: issue of creating external database datas source
* fix: bug
* fix: bug
* fix: read table bug
* feat: sync fields
* fix: test
* chore: remove refresh btn
* fix: build
* fix: underscored table name
* fix: ds reload
* fix: bug
* fix: update fields
* fix: interface
* fix: sync fields
* fix: bug
* fix: bug
* fix: test
---------
Co-authored-by: xilesun <2013xile@gmail.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, ReactNode, useRef, useCallback } from "react";
|
||||
import React, { createContext, useContext, ReactNode, useRef, useCallback } from 'react';
|
||||
|
||||
export const BlockConfigsContext = createContext<{
|
||||
getConfigs: () => any;
|
||||
@@ -31,34 +31,34 @@ export const useBlockConfigs = () => {
|
||||
export const BlockConfigsProvider = ({ children, value: initialValue }: { children: ReactNode; value: any }) => {
|
||||
const valueRef = useRef(initialValue);
|
||||
const subscribersRef = useRef<Array<() => void>>([]);
|
||||
|
||||
|
||||
const getConfigs = useCallback(() => {
|
||||
return valueRef.current;
|
||||
}, []);
|
||||
|
||||
const setConfigs = useCallback((newValue: any, shouldNotify = false) => {
|
||||
valueRef.current = newValue;
|
||||
|
||||
|
||||
// 仅在明确要求通知时执行回调
|
||||
if (shouldNotify) {
|
||||
subscribersRef.current.forEach(callback => callback());
|
||||
subscribersRef.current.forEach((callback) => callback());
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const getFiltersConfigs = useCallback(() => {
|
||||
return valueRef.current?.configData?.filterSteps || {};
|
||||
}, []);
|
||||
|
||||
|
||||
const getEventsConfigs = useCallback(() => {
|
||||
return valueRef.current?.configData?.events || {};
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
subscribersRef.current.push(callback);
|
||||
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
subscribersRef.current = subscribersRef.current.filter(cb => cb !== callback);
|
||||
subscribersRef.current = subscribersRef.current.filter((cb) => cb !== callback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -67,12 +67,8 @@ export const BlockConfigsProvider = ({ children, value: initialValue }: { childr
|
||||
setConfigs,
|
||||
getFiltersConfigs,
|
||||
getEventsConfigs,
|
||||
subscribe
|
||||
subscribe,
|
||||
}).current;
|
||||
|
||||
return (
|
||||
<BlockConfigsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</BlockConfigsContext.Provider>
|
||||
);
|
||||
};
|
||||
return <BlockConfigsContext.Provider value={contextValue}>{children}</BlockConfigsContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -7,4 +7,4 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './BlockConfigsProvider';
|
||||
export * from './BlockConfigsProvider';
|
||||
|
||||
@@ -398,7 +398,7 @@ export const useFilterAction = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const useCreateAction = (actionCallback?: (values: any, collections: any[]) => void) => {
|
||||
export const useCreateAction = (actionCallback?: (values: any) => void) => {
|
||||
const form = useForm();
|
||||
const field = useField();
|
||||
const ctx = useActionContext();
|
||||
@@ -410,14 +410,9 @@ export const useCreateAction = (actionCallback?: (values: any, collections: any[
|
||||
await form.submit();
|
||||
field.data = field.data || {};
|
||||
field.data.loading = true;
|
||||
let collections = [];
|
||||
if (!form.values.addAllCollections) {
|
||||
collections = form.values.collections;
|
||||
}
|
||||
delete form.values.collections;
|
||||
const res = await resource.create({ values: form.values });
|
||||
ctx.setVisible(false);
|
||||
await actionCallback?.(res?.data?.data, collections);
|
||||
actionCallback?.(res?.data?.data);
|
||||
await form.reset();
|
||||
field.data.loading = false;
|
||||
refresh();
|
||||
|
||||
@@ -93,6 +93,7 @@ export class CollectionPlugin extends Plugin {
|
||||
this.dataSourceManager.addDataSource(MainDataSource, {
|
||||
key: DEFAULT_DATA_SOURCE_KEY,
|
||||
displayName: DEFAULT_DATA_SOURCE_TITLE,
|
||||
status: 'loaded',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export class DataSourceManager {
|
||||
key: DEFAULT_DATA_SOURCE_KEY,
|
||||
displayName: DEFAULT_DATA_SOURCE_TITLE,
|
||||
collections: options.collections || [],
|
||||
status: 'loaded',
|
||||
});
|
||||
(options.dataSources || []).forEach((dataSourceOptions) => {
|
||||
this.addDataSource(LocalDataSource, dataSourceOptions);
|
||||
@@ -70,6 +71,19 @@ export class DataSourceManager {
|
||||
return filterDataSource ? _.filter(allDataSources, filterDataSource) : allDataSources;
|
||||
}
|
||||
|
||||
setDataSources(dataSources: DataSourceOptions[]) {
|
||||
const keyByDataSources = _.keyBy(dataSources, 'key');
|
||||
Object.keys(this.dataSourceInstancesMap).forEach((key) => {
|
||||
const newDataSource = keyByDataSources[key];
|
||||
if (newDataSource) {
|
||||
this.dataSourceInstancesMap[key].setOptions({
|
||||
displayName: newDataSource.displayName,
|
||||
status: newDataSource.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getDataSource(key?: string) {
|
||||
return key ? this.dataSourceInstancesMap[key] : this.dataSourceInstancesMap[DEFAULT_DATA_SOURCE_KEY];
|
||||
}
|
||||
|
||||
@@ -1227,10 +1227,18 @@
|
||||
"Calendar Month": "日历月",
|
||||
"Calendar Year": "日历年",
|
||||
"Scan to input": "扫码录入",
|
||||
"Date variables(Deprecated)":"日期变量(废弃)",
|
||||
"Disable manual input": "禁止手动输入",
|
||||
"Enable Scan": "启用扫码录入",
|
||||
"Available Collections": "可选数据表",
|
||||
"Selected Collections": "已选数据表",
|
||||
"Search collections...": "搜索数据表...",
|
||||
"items": "项",
|
||||
"item": "项",
|
||||
"Load collections": "加载数据表",
|
||||
"Are you sure you want to load {{count}} collection(s)?": "你确定要加载 {{count}} 个数据表吗?",
|
||||
"Confirm Load Collections": "确认加载数据表",
|
||||
"Auto focus": "自动聚焦",
|
||||
"Date variables(Deprecated)": "日期变量(废弃)",
|
||||
"{{#label}} length must be {{#limit}} characters long": "{{#label}} 长度必须是 {{#limit}} 个字符",
|
||||
"Min value": "最小值",
|
||||
"Max value": "最大值",
|
||||
|
||||
@@ -24,6 +24,7 @@ type TableVoidProps = TableProps<any> & {
|
||||
options?: Options<any, any> & { uid?: string },
|
||||
props?: any,
|
||||
) => Result<any, any> & { state?: any; setState?: any };
|
||||
onSuccess?: (data?: any) => void;
|
||||
};
|
||||
|
||||
const pageSizeOptions = [5, 10, 20, 50, 100, 200];
|
||||
@@ -94,7 +95,12 @@ const useDefSelectedRowKeys = () => {
|
||||
|
||||
export const TableVoid: React.FC<TableVoidProps> = observer(
|
||||
(props) => {
|
||||
const { rowKey = 'id', useDataSource = useDef, useSelectedRowKeys = useDefSelectedRowKeys } = props;
|
||||
const {
|
||||
rowKey = 'id',
|
||||
useDataSource = useDef,
|
||||
useSelectedRowKeys = useDefSelectedRowKeys,
|
||||
onSuccess: propsOnSuccess,
|
||||
} = props;
|
||||
const field = useField<Field>();
|
||||
const fieldSchema = useFieldSchema();
|
||||
const form = useMemo(() => createForm(), []);
|
||||
@@ -115,6 +121,9 @@ export const TableVoid: React.FC<TableVoidProps> = observer(
|
||||
}
|
||||
field.componentProps.pagination.current = data?.meta?.page || 1;
|
||||
field.componentProps.pagination.pageSize = data?.meta?.pageSize || 10;
|
||||
|
||||
// 调用从 props 传入的 onSuccess 回调
|
||||
propsOnSuccess?.(data);
|
||||
},
|
||||
},
|
||||
props,
|
||||
|
||||
@@ -9,22 +9,23 @@
|
||||
|
||||
import { DataSource } from './data-source';
|
||||
import { DataSourceManager } from './data-source-manager';
|
||||
import { DataSourceConstructor } from './types';
|
||||
|
||||
export class DataSourceFactory {
|
||||
public collectionTypes: Map<string, typeof DataSource> = new Map();
|
||||
public collectionTypes: Map<string, DataSourceConstructor> = new Map();
|
||||
|
||||
constructor(protected dataSourceManager: DataSourceManager) {}
|
||||
|
||||
register(type: string, dataSourceClass: typeof DataSource) {
|
||||
register(type: string, dataSourceClass: DataSourceConstructor) {
|
||||
this.collectionTypes.set(type, dataSourceClass);
|
||||
}
|
||||
|
||||
getClass(type: string): typeof DataSource {
|
||||
return this.collectionTypes.get(type);
|
||||
getClass<T extends DataSource = DataSource>(type: string): DataSourceConstructor<T> {
|
||||
return this.collectionTypes.get(type) as DataSourceConstructor<T>;
|
||||
}
|
||||
|
||||
create(type: string, options: any = {}): DataSource {
|
||||
const klass = this.collectionTypes.get(type);
|
||||
create<T extends DataSource = DataSource>(type: string, options: any = {}): T {
|
||||
const klass = this.getClass<T>(type);
|
||||
if (!klass) {
|
||||
throw new Error(`Data source type "${type}" not found`);
|
||||
}
|
||||
@@ -36,8 +37,7 @@ export class DataSourceFactory {
|
||||
if (environment) {
|
||||
Object.assign(opts, environment.renderJsonTemplate(others));
|
||||
}
|
||||
// @ts-ignore
|
||||
const dataSource = new klass(opts) as DataSource;
|
||||
const dataSource = new klass(opts);
|
||||
dataSource.setDataSourceManager(this.dataSourceManager);
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createConsoleLogger, createLogger, Logger, LoggerOptions } from '@nocob
|
||||
import { ToposortOptions } from '@nocobase/utils';
|
||||
import { DataSource } from './data-source';
|
||||
import { DataSourceFactory } from './data-source-factory';
|
||||
import { DataSourceConstructor } from './types';
|
||||
|
||||
type DataSourceHook = (dataSource: DataSource) => void;
|
||||
|
||||
@@ -101,11 +102,11 @@ export class DataSourceManager {
|
||||
};
|
||||
}
|
||||
|
||||
registerDataSourceType(type: string, DataSourceClass: typeof DataSource) {
|
||||
registerDataSourceType(type: string, DataSourceClass: DataSourceConstructor) {
|
||||
this.factory.register(type, DataSourceClass);
|
||||
}
|
||||
|
||||
getDataSourceType(type: string): typeof DataSource | undefined {
|
||||
getDataSourceType(type: string): DataSourceConstructor | undefined {
|
||||
return this.factory.getClass(type);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Database } from '@nocobase/database';
|
||||
import { DatabaseIntrospector } from './database-introspector/database-introspector';
|
||||
import { PostgresIntrospector } from './database-introspector/postgres-introspector';
|
||||
import { MariaDBIntrospector } from './database-introspector/mariadb-introspector';
|
||||
import { DataSource } from './data-source';
|
||||
import { Context } from '@nocobase/actions';
|
||||
import { CollectionOptions, FieldOptions } from './types';
|
||||
|
||||
export abstract class DatabaseDataSource<T extends DatabaseIntrospector = DatabaseIntrospector> extends DataSource {
|
||||
declare introspector: T;
|
||||
|
||||
createDatabaseIntrospector(db: Database): T {
|
||||
if (db.isPostgresCompatibleDialect()) {
|
||||
return new PostgresIntrospector({
|
||||
db,
|
||||
}) as unknown as T;
|
||||
}
|
||||
const dialect = db.sequelize.getDialect();
|
||||
switch (dialect) {
|
||||
case 'mariadb':
|
||||
return new MariaDBIntrospector({
|
||||
db,
|
||||
}) as T;
|
||||
default:
|
||||
return new DatabaseIntrospector({
|
||||
db,
|
||||
}) as T;
|
||||
}
|
||||
}
|
||||
|
||||
abstract readTables(): Promise<any>;
|
||||
abstract loadTables(ctx: Context, tables: string[]): Promise<any>;
|
||||
|
||||
mergeWithLoadedCollections(
|
||||
collections: CollectionOptions[],
|
||||
loadedCollections: {
|
||||
[name: string]: { name: string; fields: FieldOptions[] };
|
||||
},
|
||||
): CollectionOptions[] {
|
||||
return collections.map((collection) => {
|
||||
const loadedCollection = loadedCollections[collection.name];
|
||||
if (!loadedCollection) return collection;
|
||||
|
||||
const collectionFields = collection.fields || [];
|
||||
const loadedFieldsObj = Object.fromEntries(
|
||||
(loadedCollection.fields || []).map((f) => [f.columnName || f.field || f.name, f]),
|
||||
);
|
||||
|
||||
// Merge existing fields
|
||||
const newFields = collectionFields.map((field) => {
|
||||
const loadedField = loadedFieldsObj[field.name];
|
||||
if (!loadedField) return field;
|
||||
if (loadedField.possibleTypes) {
|
||||
loadedField.possibleTypes = field.possibleTypes;
|
||||
}
|
||||
return this.mergeFieldOptions(field, loadedField);
|
||||
});
|
||||
|
||||
// Add association fields from loaded data
|
||||
const loadedAssociationFields = (loadedCollection.fields || []).filter((field) =>
|
||||
['belongsTo', 'belongsToMany', 'hasMany', 'hasOne', 'belongsToArray'].includes(field.type),
|
||||
);
|
||||
|
||||
newFields.push(...loadedAssociationFields);
|
||||
|
||||
return {
|
||||
...collection,
|
||||
...Object.fromEntries(Object.entries(loadedCollection).filter(([key]) => key !== 'fields')),
|
||||
fields: newFields,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private mergeFieldOptions(fieldOptions: FieldOptions, modelOptions: FieldOptions): FieldOptions {
|
||||
const newOptions = {
|
||||
...fieldOptions,
|
||||
...modelOptions,
|
||||
};
|
||||
|
||||
if (fieldOptions.rawType && modelOptions.rawType && fieldOptions.rawType !== modelOptions.rawType) {
|
||||
newOptions.type = fieldOptions.type;
|
||||
newOptions.interface = fieldOptions.interface;
|
||||
newOptions.rawType = fieldOptions.rawType;
|
||||
}
|
||||
|
||||
for (const key of [...new Set([...Object.keys(modelOptions), ...Object.keys(fieldOptions)])]) {
|
||||
if (modelOptions[key] === null && fieldOptions[key]) {
|
||||
newOptions[key] = fieldOptions[key];
|
||||
}
|
||||
}
|
||||
|
||||
return newOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This program is offered under a commercial license.
|
||||
* For more information, see <https://www.nocobase.com/agreement>
|
||||
*/
|
||||
|
||||
import { Database, Repository, fieldTypeMap } from '@nocobase/database';
|
||||
import EventEmitter from 'events';
|
||||
import lodash from 'lodash';
|
||||
import { ColumnsDescription } from 'sequelize';
|
||||
import { isArray } from 'mathjs';
|
||||
import { typeInterfaceMap } from './type-interface-map';
|
||||
import {
|
||||
CollectionOptions,
|
||||
FieldInferResult,
|
||||
FieldOptions,
|
||||
PartialCollectionOptions,
|
||||
UnsupportedFieldOptions,
|
||||
tableInfo,
|
||||
} from '../types';
|
||||
|
||||
type GetCollectionOptions = {
|
||||
tableInfo: tableInfo;
|
||||
localOptions?: PartialCollectionOptions;
|
||||
mergedOptions?: PartialCollectionOptions;
|
||||
};
|
||||
|
||||
interface DatabaseIntrospectorOptions {
|
||||
db: Database;
|
||||
typeInterfaceMap?: any;
|
||||
}
|
||||
|
||||
export class DatabaseIntrospector extends EventEmitter {
|
||||
db: Database;
|
||||
|
||||
constructor(options: DatabaseIntrospectorOptions) {
|
||||
super();
|
||||
this.db = options.db;
|
||||
}
|
||||
|
||||
protected getFieldTypeMap(): Record<string, string | Array<string>> {
|
||||
return fieldTypeMap[this.db.sequelize.getDialect()];
|
||||
}
|
||||
|
||||
protected getTypeInterfaceConfig(type: string): Record<string, any> {
|
||||
return typeInterfaceMap[type] || {};
|
||||
}
|
||||
|
||||
async getTableList() {
|
||||
return await this.db.sequelize.getQueryInterface().showAllTables();
|
||||
}
|
||||
|
||||
async getTableColumnsInfo(tableInfo: tableInfo) {
|
||||
return this.db.sequelize.getQueryInterface().describeTable(tableInfo);
|
||||
}
|
||||
|
||||
async getTableConstraints(tableInfo: tableInfo) {
|
||||
return this.db.sequelize.getQueryInterface().showIndex(tableInfo.tableName);
|
||||
}
|
||||
|
||||
async getViewList() {
|
||||
return (await this.db.queryInterface.listViews()).map((view) => view.name);
|
||||
}
|
||||
|
||||
excludeViewsOrTables() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getTables(options: any = {}) {
|
||||
let tableList = await this.getTableList();
|
||||
const views = options.views?.length ? options.views : await this.getViewList();
|
||||
|
||||
tableList = tableList.concat(views);
|
||||
|
||||
tableList = tableList.filter((tableName) => {
|
||||
return !this.excludeViewsOrTables().includes(tableName);
|
||||
});
|
||||
if (this.db.options.tablePrefix) {
|
||||
tableList = tableList.filter((tableName) => {
|
||||
return tableName.startsWith(this.db.options.tablePrefix);
|
||||
});
|
||||
}
|
||||
return tableList;
|
||||
}
|
||||
|
||||
async getCollection(options: GetCollectionOptions): Promise<CollectionOptions> {
|
||||
const { tableInfo, localOptions = {}, mergedOptions = {} } = options;
|
||||
|
||||
const columnsInfo = await this.getTableColumnsInfo(tableInfo);
|
||||
|
||||
const constraints = await this.getTableConstraints(tableInfo);
|
||||
const collectionOptions = this.tableInfoToCollectionOptions(tableInfo);
|
||||
|
||||
try {
|
||||
const fields: Array<FieldInferResult> = Object.keys(columnsInfo).map((columnName) => {
|
||||
return this.columnInfoToFieldOptions(columnsInfo, columnName, constraints);
|
||||
});
|
||||
|
||||
const unsupportedFields: Array<UnsupportedFieldOptions> = fields.filter((field) => {
|
||||
// @ts-ignore
|
||||
return field.supported === false;
|
||||
}) as Array<UnsupportedFieldOptions>;
|
||||
|
||||
const supportFields: Array<FieldOptions> = fields.filter((field) => {
|
||||
// @ts-ignore
|
||||
return field.supported !== false;
|
||||
}) as Array<FieldOptions>;
|
||||
|
||||
this.db.logger.debug('Processing collection fields', {
|
||||
tableName: tableInfo.tableName,
|
||||
totalFields: fields.length,
|
||||
supportedFields: supportFields.length,
|
||||
unsupportedFields: unsupportedFields.length,
|
||||
});
|
||||
|
||||
const remoteCollectionInfo: CollectionOptions = {
|
||||
...collectionOptions,
|
||||
...this.collectionOptionsByFields(supportFields),
|
||||
...localOptions,
|
||||
...mergedOptions,
|
||||
fields: supportFields,
|
||||
};
|
||||
|
||||
if (unsupportedFields.length) {
|
||||
remoteCollectionInfo.unsupportedFields = unsupportedFields;
|
||||
this.db.logger.debug('Found unsupported fields', {
|
||||
tableName: tableInfo.tableName,
|
||||
fields: unsupportedFields.map((f) => ({ name: f.name, type: f.rawType })),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
remoteCollectionInfo.view &&
|
||||
!remoteCollectionInfo.filterTargetKey &&
|
||||
supportFields.find((field) => field.name === 'id')
|
||||
) {
|
||||
remoteCollectionInfo.filterTargetKey = 'id';
|
||||
this.db.logger.debug('Set view filterTargetKey to id', {
|
||||
tableName: tableInfo.tableName,
|
||||
});
|
||||
}
|
||||
|
||||
return remoteCollectionInfo;
|
||||
} catch (e) {
|
||||
this.db.logger.error('Collection introspection error', {
|
||||
tableName: tableInfo.tableName,
|
||||
error: e.message,
|
||||
stack: e.stack,
|
||||
});
|
||||
throw new Error(`table ${tableInfo.tableName} introspection error: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
public tableInfoToCollectionOptions(tableInfo: tableInfo) {
|
||||
const tableName = tableInfo.tableName;
|
||||
let name = tableName;
|
||||
|
||||
if (this.db.options.tablePrefix) {
|
||||
name = tableName.replace(this.db.options.tablePrefix, '');
|
||||
}
|
||||
//replace dot to underscore
|
||||
name = name.replace(/\./g, '_');
|
||||
|
||||
return {
|
||||
name,
|
||||
title: name,
|
||||
schema: tableInfo.schema,
|
||||
tableName,
|
||||
};
|
||||
}
|
||||
|
||||
protected extractTypeFromDefinition(rawType: string) {
|
||||
const leftParenIndex = rawType.indexOf('(');
|
||||
|
||||
if (leftParenIndex === -1) {
|
||||
return rawType.toLowerCase();
|
||||
}
|
||||
|
||||
return rawType.substring(0, leftParenIndex).toLowerCase().trim();
|
||||
}
|
||||
|
||||
protected inferFieldTypeByRawType(rawType: string): any {
|
||||
const fieldTypeMap = this.getFieldTypeMap();
|
||||
const queryType = this.extractTypeFromDefinition(rawType);
|
||||
|
||||
const mappedType = fieldTypeMap[queryType];
|
||||
|
||||
if (isArray(mappedType)) {
|
||||
return {
|
||||
type: mappedType[0],
|
||||
possibleTypes: mappedType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: mappedType,
|
||||
};
|
||||
}
|
||||
|
||||
protected inferFieldOptionsByRawType(type: string, rawType: string) {
|
||||
const fieldClass = this.db.fieldTypes.get(type);
|
||||
|
||||
if (!fieldClass) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof fieldClass.optionsFromRawType === 'function') {
|
||||
return fieldClass.optionsFromRawType(rawType);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
protected columnInfoToFieldOptions(
|
||||
columnsInfo: ColumnsDescription,
|
||||
columnName: string,
|
||||
indexes: any,
|
||||
): FieldInferResult {
|
||||
const columnInfo = columnsInfo[columnName];
|
||||
|
||||
const name = columnName;
|
||||
let fieldOptions: FieldOptions = {
|
||||
...this.columnAttribute(columnsInfo, columnName, indexes),
|
||||
...this.inferFieldTypeByRawType(columnInfo.type),
|
||||
rawType: columnInfo.type,
|
||||
name,
|
||||
field: columnName,
|
||||
};
|
||||
|
||||
Object.assign(fieldOptions, this.inferFieldOptionsByRawType(fieldOptions.type, columnInfo.type));
|
||||
|
||||
if (!fieldOptions.type) {
|
||||
return {
|
||||
rawType: columnInfo.type,
|
||||
name,
|
||||
field: columnName,
|
||||
supported: false,
|
||||
};
|
||||
}
|
||||
|
||||
const interfaceConfig = this.getDefaultInterfaceByType(columnsInfo, columnName, fieldOptions.type);
|
||||
|
||||
if (typeof interfaceConfig === 'string') {
|
||||
fieldOptions.interface = interfaceConfig;
|
||||
} else {
|
||||
fieldOptions = {
|
||||
...fieldOptions,
|
||||
...interfaceConfig,
|
||||
};
|
||||
}
|
||||
|
||||
lodash.set(fieldOptions, 'uiSchema.title', columnName);
|
||||
|
||||
return fieldOptions;
|
||||
}
|
||||
|
||||
protected columnAttribute(columnsInfo: ColumnsDescription, columnName: string, indexes: any) {
|
||||
const columnInfo = columnsInfo[columnName];
|
||||
|
||||
const attr: any = {
|
||||
type: columnInfo.type,
|
||||
allowNull: columnInfo.defaultValue ? true : columnInfo.allowNull,
|
||||
primaryKey: columnInfo.primaryKey,
|
||||
unique: false,
|
||||
autoIncrement: columnInfo.autoIncrement,
|
||||
description: columnInfo.comment,
|
||||
elementType: columnInfo['elementType'],
|
||||
};
|
||||
|
||||
if (columnInfo.defaultValue && typeof columnInfo.defaultValue === 'string') {
|
||||
const isSerial = columnInfo.defaultValue.match(/^nextval\(/);
|
||||
const isUUID = columnInfo.defaultValue.match(/^uuid_generate_v4\(/);
|
||||
|
||||
if (isSerial || isUUID) {
|
||||
attr.autoIncrement = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const index of indexes) {
|
||||
if (index.fields.length == 1 && index.fields[0].attribute == columnName && index.unique) {
|
||||
attr.unique = true;
|
||||
}
|
||||
}
|
||||
|
||||
return attr;
|
||||
}
|
||||
|
||||
private collectionOptionsByFields(fields: FieldOptions[]): PartialCollectionOptions {
|
||||
const options: PartialCollectionOptions = {
|
||||
timestamps: false,
|
||||
autoGenId: false,
|
||||
};
|
||||
|
||||
const autoIncrementField = fields.find((field) => field.autoIncrement);
|
||||
|
||||
if (autoIncrementField) {
|
||||
options.filterTargetKey = autoIncrementField.name;
|
||||
}
|
||||
|
||||
const primaryKeys = fields.filter((field) => field.primaryKey);
|
||||
|
||||
if (primaryKeys.length > 1) {
|
||||
options.filterTargetKey = primaryKeys.map((field) => field.name);
|
||||
}
|
||||
|
||||
if (!options.filterTargetKey && primaryKeys.length == 1) {
|
||||
options.filterTargetKey = primaryKeys[0].name;
|
||||
}
|
||||
|
||||
const uniques = fields.filter((field) => field.unique);
|
||||
|
||||
if (!options.filterTargetKey && uniques.length == 1) {
|
||||
options.filterTargetKey = uniques[0].name;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private getDefaultInterfaceByType(columnsInfo, columnName, type: string): object | string {
|
||||
const interfaceConfig = this.getTypeInterfaceConfig(type);
|
||||
|
||||
let interfaceRes = interfaceConfig;
|
||||
if (typeof interfaceConfig === 'function') {
|
||||
interfaceRes = interfaceConfig(columnsInfo[columnName]);
|
||||
}
|
||||
|
||||
return interfaceRes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DatabaseIntrospector } from './database-introspector';
|
||||
|
||||
export class MariaDBIntrospector extends DatabaseIntrospector {
|
||||
async getTableList() {
|
||||
const tables = await this.db.sequelize.getQueryInterface().showAllTables();
|
||||
return tables.map((table: any) => {
|
||||
return table.tableName;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DatabaseIntrospector } from './database-introspector';
|
||||
import _ from 'lodash';
|
||||
import { ColumnsDescription } from 'sequelize';
|
||||
import { tableInfo } from '../types';
|
||||
|
||||
export class PostgresIntrospector extends DatabaseIntrospector {
|
||||
async getTableConstraints(tableInfo: tableInfo): Promise<object> {
|
||||
const schema = tableInfo.schema || 'public';
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
tc.constraint_schema,
|
||||
tc.constraint_name,
|
||||
tc.table_name,
|
||||
kcu.column_name,
|
||||
CASE WHEN (SELECT COUNT(*)
|
||||
FROM information_schema.key_column_usage kcu2
|
||||
WHERE kcu2.constraint_name = tc.constraint_name
|
||||
AND kcu2.table_schema = tc.constraint_schema) = 1 THEN TRUE
|
||||
ELSE FALSE END AS is_single_column
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE tc.constraint_type = 'UNIQUE'
|
||||
AND tc.table_name = :tableName
|
||||
AND tc.table_schema = :schema
|
||||
ORDER BY tc.constraint_name, kcu.column_name;
|
||||
`;
|
||||
|
||||
return await this.db.sequelize.query(sql, {
|
||||
type: 'SELECT',
|
||||
replacements: {
|
||||
tableName: tableInfo.tableName,
|
||||
schema,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTableColumnsInfo(tableInfo: tableInfo) {
|
||||
const columnsInfo = await this.db.sequelize.getQueryInterface().describeTable(tableInfo);
|
||||
const primaryKeys = await this.getPrimaryKeysOfTable(tableInfo);
|
||||
|
||||
// adjust primary key attributes
|
||||
Object.keys(columnsInfo).forEach((columnName) => {
|
||||
const columnInfo = columnsInfo[columnName];
|
||||
const primaryKey = primaryKeys.find((key: any) => key.column_name === columnName);
|
||||
if (primaryKey) {
|
||||
columnInfo.primaryKey = true;
|
||||
}
|
||||
});
|
||||
await this.appendArrayColumnElementType(tableInfo, columnsInfo);
|
||||
|
||||
return columnsInfo;
|
||||
}
|
||||
|
||||
private async getPrimaryKeysOfTable(tableInfo: tableInfo) {
|
||||
const schema = tableInfo.schema || 'public';
|
||||
|
||||
const sql = `
|
||||
SELECT pg_attribute.attname AS column_name,
|
||||
pg_constraint.conname AS constraint_name
|
||||
FROM pg_constraint
|
||||
JOIN pg_class ON pg_constraint.conrelid = pg_class.oid
|
||||
JOIN pg_attribute ON pg_attribute.attnum = ANY (pg_constraint.conkey)
|
||||
AND pg_attribute.attrelid = pg_class.oid
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
||||
WHERE pg_class.relname = :tableName
|
||||
AND pg_namespace.nspname = :schema
|
||||
AND pg_constraint.contype = 'p';
|
||||
`;
|
||||
|
||||
return await this.db.sequelize.query(sql, {
|
||||
type: 'SELECT',
|
||||
replacements: {
|
||||
tableName: tableInfo.tableName,
|
||||
schema,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected columnAttribute(columnsInfo: ColumnsDescription, columnName: string, indexes: any) {
|
||||
const columnInfo = columnsInfo[columnName];
|
||||
|
||||
const attr: any = {
|
||||
type: columnInfo.type,
|
||||
allowNull: columnInfo.defaultValue ? true : columnInfo.allowNull,
|
||||
primaryKey: columnInfo.primaryKey,
|
||||
unique: false,
|
||||
description: columnInfo.comment,
|
||||
};
|
||||
|
||||
if (columnInfo.defaultValue && typeof columnInfo.defaultValue === 'string') {
|
||||
const isSerial = columnInfo.defaultValue.match(/^nextval\(/);
|
||||
const isUUID = columnInfo.defaultValue.match(/^uuid_generate_v4\(/);
|
||||
|
||||
if (isSerial || isUUID) {
|
||||
attr.autoIncrement = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (columnInfo.type === 'ARRAY') {
|
||||
attr.dataType = 'array';
|
||||
if (columnInfo['elementType']) {
|
||||
const elementType = columnInfo['elementType'].replace(/"/g, '');
|
||||
const { type } = this.inferFieldTypeByRawType(elementType);
|
||||
attr.elementType = type;
|
||||
}
|
||||
}
|
||||
|
||||
for (const index of indexes) {
|
||||
if (index['is_single_column'] && index['column_name'] === columnName) {
|
||||
attr.unique = true;
|
||||
}
|
||||
}
|
||||
|
||||
return attr;
|
||||
}
|
||||
|
||||
private async getArrayColumnElementType(tableInfo: tableInfo, columnNames: string[]) {
|
||||
const schema = tableInfo.schema || 'public';
|
||||
// https://www.postgresql.org/docs/current/infoschema-element-types.html
|
||||
const sql = `
|
||||
SELECT c.column_name, e.data_type AS element_type
|
||||
FROM information_schema.columns c LEFT JOIN information_schema.element_types e
|
||||
ON ((c.table_catalog, c.table_schema, c.table_name, 'TABLE', c.dtd_identifier)
|
||||
= (e.object_catalog, e.object_schema, e.object_name, e.object_type, e.collection_type_identifier))
|
||||
WHERE c.table_schema = :schema AND c.table_name = :tableName AND c.column_name in (:columnNames);
|
||||
`;
|
||||
|
||||
return await this.db.sequelize.query(sql, {
|
||||
type: 'SELECT',
|
||||
replacements: {
|
||||
schema,
|
||||
tableName: tableInfo.tableName,
|
||||
columnNames,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async appendArrayColumnElementType(tableInfo: tableInfo, columnsInfo: ColumnsDescription) {
|
||||
const arrayColumns = {};
|
||||
for (const columnName in columnsInfo) {
|
||||
const columnInfo = columnsInfo[columnName];
|
||||
if (columnInfo.type === 'ARRAY') {
|
||||
arrayColumns[columnName] = columnInfo;
|
||||
}
|
||||
}
|
||||
const columnNames = Object.keys(arrayColumns);
|
||||
if (!columnNames.length) {
|
||||
return;
|
||||
}
|
||||
const rows = await this.getArrayColumnElementType(tableInfo, columnNames);
|
||||
const udtNameMap = _.keyBy(rows, 'column_name') as {
|
||||
[key: string]: { element_type: string };
|
||||
};
|
||||
Object.keys(arrayColumns).forEach((columnName) => {
|
||||
columnsInfo[columnName]['elementType'] = udtNameMap[columnName].element_type;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const typeInterfaceMap = {
|
||||
// ---------- JSON / Structured ----------
|
||||
array: () => ({
|
||||
interface: 'json',
|
||||
uiSchema: {
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
autoSize: { minRows: 5 },
|
||||
},
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
json: () => ({
|
||||
interface: 'json',
|
||||
uiSchema: {
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
autoSize: { minRows: 5 },
|
||||
},
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
jsonb: () => ({
|
||||
interface: 'json',
|
||||
uiSchema: {
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
autoSize: { minRows: 5 },
|
||||
},
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
|
||||
// ---------- Date / Time ----------
|
||||
date: () => ({
|
||||
interface: 'datetime',
|
||||
uiSchema: {
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': { dateFormat: 'YYYY-MM-DD', showTime: false },
|
||||
},
|
||||
}),
|
||||
datetime: () => ({
|
||||
interface: 'datetime',
|
||||
uiSchema: {
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': { dateFormat: 'YYYY-MM-DD', showTime: false },
|
||||
},
|
||||
}),
|
||||
datetimeTz: () => ({
|
||||
interface: 'datetime',
|
||||
uiSchema: {
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': { dateFormat: 'YYYY-MM-DD', showTime: false },
|
||||
},
|
||||
}),
|
||||
datetimeNoTz: () => ({
|
||||
interface: 'datetimeNoTz',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': { showTime: false, utc: false },
|
||||
},
|
||||
}),
|
||||
dateOnly: () => ({
|
||||
interface: 'dateOnly',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': { dateOnly: true },
|
||||
},
|
||||
}),
|
||||
time: () => ({
|
||||
interface: 'time',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
'x-component': 'TimePicker',
|
||||
'x-component-props': { format: 'HH:mm:ss' },
|
||||
},
|
||||
}),
|
||||
|
||||
// ---------- Numeric ----------
|
||||
integer: () => ({
|
||||
interface: 'integer',
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
'x-component': 'InputNumber',
|
||||
'x-component-props': { stringMode: true, step: '1' },
|
||||
'x-validator': 'integer',
|
||||
},
|
||||
}),
|
||||
bigInt: () => ({
|
||||
interface: 'integer',
|
||||
uiSchema: {
|
||||
'x-component': 'InputNumber',
|
||||
'x-component-props': {
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
float: () => ({
|
||||
interface: 'number',
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
'x-component': 'InputNumber',
|
||||
'x-component-props': { stringMode: true, step: '1' },
|
||||
},
|
||||
}),
|
||||
double: () => ({
|
||||
interface: 'number',
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
'x-component': 'InputNumber',
|
||||
'x-component-props': { stringMode: true, step: '1' },
|
||||
},
|
||||
}),
|
||||
real: () => ({
|
||||
interface: 'number',
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
'x-component': 'InputNumber',
|
||||
'x-component-props': { stringMode: true, step: '1' },
|
||||
},
|
||||
}),
|
||||
decimal: () => ({
|
||||
interface: 'number',
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
'x-component': 'InputNumber',
|
||||
'x-component-props': { stringMode: true, step: '1' },
|
||||
},
|
||||
}),
|
||||
|
||||
// ---------- Text / String ----------
|
||||
string: () => ({
|
||||
interface: 'input',
|
||||
uiSchema: {
|
||||
'x-component': 'Input',
|
||||
'x-component-props': {
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => ({
|
||||
interface: 'textarea',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
'x-component': 'Input.TextArea',
|
||||
},
|
||||
}),
|
||||
password: () => ({
|
||||
interface: 'password',
|
||||
hidden: true,
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
'x-component': 'Password',
|
||||
},
|
||||
}),
|
||||
uid: () => ({
|
||||
interface: 'input',
|
||||
uiSchema: {
|
||||
'x-component': 'Input',
|
||||
'x-component-props': {
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
uuid: () => ({
|
||||
interface: 'uuid',
|
||||
uiSchema: {
|
||||
'x-component': 'Input',
|
||||
'x-component-props': {
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ---------- Boolean ----------
|
||||
boolean: () => ({
|
||||
interface: 'checkbox',
|
||||
uiSchema: {
|
||||
type: 'boolean',
|
||||
'x-component': 'Checkbox',
|
||||
},
|
||||
}),
|
||||
|
||||
// ---------- Placeholders (not yet implemented) ----------
|
||||
// Relationships
|
||||
belongsTo: '',
|
||||
belongsToMany: '',
|
||||
hasMany: '',
|
||||
hasOne: '',
|
||||
context: '',
|
||||
virtual: '',
|
||||
// Other
|
||||
radio: '',
|
||||
set: '',
|
||||
sort: '',
|
||||
// Geospatial
|
||||
point: '',
|
||||
polygon: '',
|
||||
lineString: '',
|
||||
circle: '',
|
||||
};
|
||||
@@ -11,9 +11,11 @@ export * from './collection-manager';
|
||||
export * from './collection';
|
||||
export * from './data-source';
|
||||
export * from './data-source-manager';
|
||||
export * from './database-data-source';
|
||||
export * from './sequelize-collection-manager';
|
||||
export * from './sequelize-data-source';
|
||||
export * from './load-default-actions';
|
||||
export * from './types';
|
||||
export * from './database-introspector/database-introspector';
|
||||
|
||||
export * from './utils';
|
||||
|
||||
@@ -7,10 +7,21 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DataSource } from './data-source';
|
||||
import { DataSourceOptions } from './data-source';
|
||||
import { DatabaseDataSource } from './database-data-source';
|
||||
import { DatabaseIntrospector } from './database-introspector/database-introspector';
|
||||
import { SequelizeCollectionManager } from './sequelize-collection-manager';
|
||||
|
||||
export class SequelizeDataSource extends DataSource {
|
||||
export abstract class SequelizeDataSource<
|
||||
T extends DatabaseIntrospector = DatabaseIntrospector,
|
||||
> extends DatabaseDataSource<T> {
|
||||
declare collectionManager: SequelizeCollectionManager;
|
||||
|
||||
constructor(options: DataSourceOptions) {
|
||||
super(options);
|
||||
this.introspector = this.createDatabaseIntrospector(this.collectionManager.db);
|
||||
}
|
||||
|
||||
createCollectionManager(options?: any) {
|
||||
return new SequelizeCollectionManager(options.collectionManager);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,44 @@
|
||||
|
||||
import { DataSource } from './data-source';
|
||||
|
||||
export type CollectionOptions = {
|
||||
name: string;
|
||||
repository?: string;
|
||||
filterTargetKey?: string | Array<string>;
|
||||
fields: any[];
|
||||
[key: string]: any;
|
||||
export type DataSourceConstructor<T extends DataSource = DataSource> = Omit<typeof DataSource, 'prototype'> & {
|
||||
new (...args: any[]): T;
|
||||
};
|
||||
|
||||
export type tableInfo = {
|
||||
tableName: string;
|
||||
schema?: string;
|
||||
};
|
||||
|
||||
export type PartialCollectionOptions = Partial<
|
||||
Omit<CollectionOptions, 'fields'> & {
|
||||
fields?: Partial<FieldOptions>[];
|
||||
}
|
||||
>;
|
||||
|
||||
export interface CollectionOptions {
|
||||
name: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
title?: string;
|
||||
template?: string;
|
||||
timestamps?: boolean;
|
||||
filterTargetKey?: string | Array<string>;
|
||||
fields: FieldOptions[];
|
||||
autoGenId?: boolean;
|
||||
view?: boolean;
|
||||
unsupportedFields?: UnsupportedFieldOptions[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface UnsupportedFieldOptions {
|
||||
rawType: string;
|
||||
name: string;
|
||||
supported: false;
|
||||
}
|
||||
|
||||
export type FieldInferResult = FieldOptions | UnsupportedFieldOptions;
|
||||
|
||||
export type FieldOptions = {
|
||||
name: string;
|
||||
field: string;
|
||||
|
||||
@@ -565,11 +565,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
): Collection<Attributes, CreateAttributes> {
|
||||
options = lodash.cloneDeep(options);
|
||||
|
||||
if (typeof options.underscored !== 'boolean') {
|
||||
if (this.options.underscored) {
|
||||
options.underscored = true;
|
||||
}
|
||||
}
|
||||
options.underscored = options.underscored ?? this.options.underscored;
|
||||
|
||||
this.logger.trace(`beforeDefineCollection: ${safeJsonStringify(options)}`, {
|
||||
databaseInstanceId: this.instanceId,
|
||||
|
||||
@@ -54,12 +54,13 @@ export * from './repository';
|
||||
export * from './relation-repository/relation-repository';
|
||||
export { default as sqlParser, SQLParserTypes } from './sql-parser';
|
||||
export * from './update-associations';
|
||||
export { snakeCase } from './utils';
|
||||
export { snakeCase, extractTypeFromDefinition } from './utils';
|
||||
export * from './value-parsers';
|
||||
export * from './view-collection';
|
||||
export { default as fieldTypeMap } from './view/field-type-map';
|
||||
|
||||
export * from './view/view-inference';
|
||||
export * from './update-guard';
|
||||
export { TableInfo } from './query-interface/query-interface';
|
||||
export { default as operators } from './operators';
|
||||
export { filterIncludes, mergeIncludes } from './utils/filter-include';
|
||||
|
||||
@@ -129,6 +129,16 @@ export function getKeysByPrefix(keys: string[], prefix: string) {
|
||||
return keys.filter((key) => key.startsWith(`${prefix}.`)).map((key) => key.substring(prefix.length + 1));
|
||||
}
|
||||
|
||||
export function extractTypeFromDefinition(rawType: string) {
|
||||
const leftParenIndex = rawType.indexOf('(');
|
||||
|
||||
if (leftParenIndex === -1) {
|
||||
return rawType.toLowerCase();
|
||||
}
|
||||
|
||||
return rawType.substring(0, leftParenIndex).toLowerCase().trim();
|
||||
}
|
||||
|
||||
export function processIncludes(includes: any[], model: any, parentAs = '') {
|
||||
includes.forEach((include: { association: string; include?: any[] }, index: number) => {
|
||||
// Process current level
|
||||
|
||||
@@ -23,6 +23,7 @@ export * from './event-queue';
|
||||
export * from './background-job-manager';
|
||||
export * from './worker-id-allocator';
|
||||
export * from './redis-connection-manager';
|
||||
export * from './main-data-source';
|
||||
export const OFFICIAL_PLUGIN_PREFIX = '@nocobase/plugin-';
|
||||
|
||||
export {
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DataSourceOptions, SequelizeDataSource } from '@nocobase/data-source-manager';
|
||||
import { Context } from '@nocobase/actions';
|
||||
import { CollectionOptions, DataSourceOptions, SequelizeDataSource } from '@nocobase/data-source-manager';
|
||||
import { Collection, Model } from '@nocobase/database';
|
||||
|
||||
type MainDataSourceStatus = 'loaded' | 'loading';
|
||||
|
||||
export class MainDataSource extends SequelizeDataSource {
|
||||
status: MainDataSourceStatus = 'loaded';
|
||||
|
||||
init(options: DataSourceOptions = {}) {
|
||||
const { acl, resourceManager, database } = options;
|
||||
|
||||
@@ -29,4 +35,146 @@ export class MainDataSource extends SequelizeDataSource {
|
||||
this.resourceManager.use(this.acl.middleware(), { group: 'acl', after: 'auth' });
|
||||
}
|
||||
}
|
||||
|
||||
async readTables() {
|
||||
const allTables = await this.introspector.getTableList();
|
||||
const existsCollections = this.collectionManager.db.collections;
|
||||
const existsTables = Array.from(existsCollections.values()).map(
|
||||
(collection: Collection) => collection.model.tableName,
|
||||
);
|
||||
const diffTables = allTables.filter((table) => !existsTables.includes(table));
|
||||
return diffTables.map((name) => ({ name }));
|
||||
}
|
||||
|
||||
private async tables2Collections(
|
||||
tableNames: (
|
||||
| string
|
||||
| {
|
||||
tableName: string;
|
||||
schema?: string;
|
||||
}
|
||||
)[],
|
||||
): Promise<CollectionOptions[]> {
|
||||
const db = this.collectionManager.db;
|
||||
const results = await Promise.all(
|
||||
tableNames.map(async (tableName) => {
|
||||
let tableInfo: { tableName: string; schema?: string };
|
||||
if (typeof tableName === 'string') {
|
||||
tableInfo = { tableName };
|
||||
if (db.options.schema) {
|
||||
tableInfo.schema = db.options.schema;
|
||||
}
|
||||
} else {
|
||||
tableInfo = tableName;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.introspector.getCollection({ tableInfo });
|
||||
} catch (e) {
|
||||
if (e.message.includes('No description found for')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
async loadTables(ctx: Context, tables: string[]) {
|
||||
const repo = this.collectionManager.db.getRepository('collections');
|
||||
const existsCollections = this.collectionManager.db.collections;
|
||||
const existsTables = Array.from(existsCollections.values()).map(
|
||||
(collection: Collection) => collection.model.tableName,
|
||||
);
|
||||
const toAddTables = tables.filter((table) => !existsTables.includes(table));
|
||||
if (toAddTables.length) {
|
||||
try {
|
||||
this.status = 'loading';
|
||||
const results = await this.tables2Collections(toAddTables);
|
||||
const values = results.map((result) => ({
|
||||
...result,
|
||||
underscored: false,
|
||||
}));
|
||||
await repo.create({ values, context: ctx });
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
this.status = 'loaded';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getLoadedCollections(filter?: any) {
|
||||
const db = this.collectionManager.db;
|
||||
const loadedCollections = await db.getRepository('collections').find({
|
||||
appends: ['fields'],
|
||||
filter: {
|
||||
hidden: false,
|
||||
...filter,
|
||||
},
|
||||
});
|
||||
const collections = loadedCollections.filter((collection: Model) => collection.options?.from !== 'db2cm');
|
||||
const loadedData = {};
|
||||
for (const collection of collections) {
|
||||
const c = db.getCollection(collection.name);
|
||||
loadedData[c.tableName()] = {
|
||||
...collection.toJSON(),
|
||||
fields: collection.fields.map((field: Model) => {
|
||||
const f = c.getField(field.name);
|
||||
return {
|
||||
columnName: f?.columnName(),
|
||||
...field.toJSON(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
return loadedData;
|
||||
}
|
||||
|
||||
async syncFieldsFromDatabase(ctx: any, collectionNames?: string[]) {
|
||||
let filter = {};
|
||||
if (collectionNames?.length) {
|
||||
filter = {
|
||||
name: collectionNames,
|
||||
};
|
||||
}
|
||||
const db = this.collectionManager.db;
|
||||
const loadedCollections = await this.getLoadedCollections(filter);
|
||||
const tableNames = Object.values(loadedCollections).map(({ name }) => {
|
||||
const collection = db.getCollection(name);
|
||||
return collection.getTableNameWithSchema();
|
||||
});
|
||||
let collections = [];
|
||||
try {
|
||||
collections = await this.tables2Collections(tableNames);
|
||||
} catch (err) {
|
||||
ctx.log.error(err);
|
||||
}
|
||||
const toLoadCollections = this.mergeWithLoadedCollections(collections, loadedCollections);
|
||||
|
||||
for (const values of toLoadCollections) {
|
||||
const existsFields = loadedCollections[values.tableName].fields;
|
||||
const deletedFields = existsFields.filter((field: any) => !values.fields.find((f) => f.name === field.name));
|
||||
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
for (const field of deletedFields) {
|
||||
await db.getRepository('fields').destroy({
|
||||
filterByTk: field.key,
|
||||
context: ctx,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await db.getRepository('collections').update({
|
||||
filterByTk: values.name,
|
||||
values,
|
||||
updateAssociationValues: ['fields'],
|
||||
context: ctx,
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +110,8 @@ export class PluginAuthServer extends Plugin {
|
||||
},
|
||||
});
|
||||
// Register actions
|
||||
Object.entries(authActions).forEach(([action, handler]) =>
|
||||
this.app.resourceManager.getResource('auth')?.addAction(action, handler),
|
||||
Object.entries(authActions).forEach(
|
||||
([action, handler]) => this.app.resourceManager.getResource('auth')?.addAction(action, handler),
|
||||
);
|
||||
Object.entries(authenticatorsActions).forEach(([action, handler]) =>
|
||||
this.app.resourceManager.registerActionHandler(`authenticators:${action}`, handler),
|
||||
|
||||
@@ -49,7 +49,7 @@ class PluginDataSourceMainClient extends Plugin {
|
||||
'x-component': 'InputNumber',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
interface: 'integer',
|
||||
interface: 'id',
|
||||
},
|
||||
});
|
||||
this.addCollectionPresetField({
|
||||
|
||||
+9
-5
@@ -316,7 +316,6 @@ SELECT * FROM numbers;
|
||||
|
||||
await app.db.sync();
|
||||
const UserCollection = app.db.getCollection('users');
|
||||
|
||||
const viewName = `t_${uid(6)}`;
|
||||
const createViewName = app.db.options.schema ? `${app.db.options.schema}.${viewName}` : viewName;
|
||||
const dropSQL = `DROP VIEW IF EXISTS ${createViewName}`;
|
||||
@@ -494,12 +493,17 @@ SELECT * FROM numbers;
|
||||
await app.db.sync();
|
||||
|
||||
const UserCollection = app.db.getCollection('users');
|
||||
|
||||
// create view
|
||||
const viewName = `t_${uid(6)}`;
|
||||
const dropSQL = `DROP VIEW IF EXISTS ${viewName}`;
|
||||
let schemaAndViewName = viewName;
|
||||
const schema = process.env.COLLECTION_MANAGER_SCHEMA || app.db.options.schema || 'public';
|
||||
let usersTableName = UserCollection.quotedTableName();
|
||||
if (db.inDialect('postgres')) {
|
||||
schemaAndViewName = `${schema}.${viewName}`;
|
||||
usersTableName = `${schema}.users`;
|
||||
}
|
||||
const dropSQL = `DROP VIEW IF EXISTS ${schemaAndViewName}`;
|
||||
await app.db.sequelize.query(dropSQL);
|
||||
const viewSQL = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
|
||||
const viewSQL = `CREATE VIEW ${schemaAndViewName} AS SELECT * FROM ${usersTableName}`;
|
||||
await app.db.sequelize.query(viewSQL);
|
||||
|
||||
// create view collection
|
||||
|
||||
+101
-6
@@ -7,19 +7,19 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import Database from '@nocobase/database';
|
||||
import Application, { Plugin } from '@nocobase/server';
|
||||
import Database, { createMockDatabase } from '@nocobase/database';
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { createApp } from '.';
|
||||
import { DataTypes, Sequelize } from 'sequelize';
|
||||
|
||||
describe('db2cm test', () => {
|
||||
let db: Database;
|
||||
let app: any;
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
describe('uiManageable test', async () => {
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
class Plugin1 extends Plugin {
|
||||
static collection = {
|
||||
name: 'hello',
|
||||
@@ -87,4 +87,99 @@ describe('db2cm test', () => {
|
||||
expect(fields.some((c) => c.name === 'name')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.env.DB_DIALECT === 'sqlite')('read db tables', async () => {
|
||||
let queryInterface: any;
|
||||
let schema: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createApp({
|
||||
plugins: ['data-source-manager'],
|
||||
});
|
||||
db = app.db;
|
||||
|
||||
queryInterface = db.sequelize.getQueryInterface();
|
||||
schema = db.options.schema;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('get available tables', async () => {
|
||||
await queryInterface.createTable(
|
||||
{
|
||||
schema,
|
||||
tableName: 'table',
|
||||
},
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await app
|
||||
.agent()
|
||||
.resource('dataSources')
|
||||
.readTables({
|
||||
values: {
|
||||
dataSourceKey: 'main',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response.body.data[0].name).toBe('table');
|
||||
});
|
||||
|
||||
it('load table', async () => {
|
||||
await queryInterface.createTable(
|
||||
{
|
||||
schema,
|
||||
tableName: 'table',
|
||||
},
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
const tables = ['table'];
|
||||
|
||||
let collections = await db.getRepository('collections').find({
|
||||
filter: { name: tables },
|
||||
});
|
||||
expect(collections.length).toBe(0);
|
||||
|
||||
const response = await app
|
||||
.agent()
|
||||
.resource('dataSources')
|
||||
.loadTables({
|
||||
values: {
|
||||
dataSourceKey: 'main',
|
||||
tables,
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
collections = await db.getRepository('collections').find({
|
||||
filter: { name: tables },
|
||||
});
|
||||
|
||||
expect(collections.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const PRESET_FIELDS_INTERFACES = ['id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'];
|
||||
@@ -149,6 +149,9 @@ export class CollectionModel extends MagicAttributeModel {
|
||||
});
|
||||
|
||||
for (const instance of instances) {
|
||||
if (!instance.get('collectionName')) {
|
||||
instance.set('collectionName', this.get('name'));
|
||||
}
|
||||
await instance.load(options);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Database } from '@nocobase/database';
|
||||
import { Collection, Database, snakeCase } from '@nocobase/database';
|
||||
import lodash from 'lodash';
|
||||
|
||||
export default {
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import PluginDataSourceMainServer from '../server';
|
||||
import { MainDataSource } from '@nocobase/server';
|
||||
|
||||
export default {
|
||||
name: 'mainDataSource',
|
||||
actions: {
|
||||
async refresh(ctx: Context, next: Next) {
|
||||
const plugin = ctx.app.pm.get('data-source-main') as PluginDataSourceMainServer;
|
||||
const mainDataSource = ctx.app.dataSourceManager.get('main') as MainDataSource;
|
||||
if (mainDataSource.status === 'loaded') {
|
||||
await plugin.loadCollections();
|
||||
}
|
||||
await next();
|
||||
},
|
||||
|
||||
async syncFields(ctx: Context, next: Next) {
|
||||
const { collections } = ctx.action.params.values || {};
|
||||
const mainDataSource = ctx.app.dataSourceManager.get('main') as MainDataSource;
|
||||
if (mainDataSource.status === 'loaded') {
|
||||
await mainDataSource.syncFieldsFromDatabase(ctx, collections);
|
||||
}
|
||||
await next();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -7,7 +7,15 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Filter, InheritedCollection, JoiValidationError, UniqueConstraintError } from '@nocobase/database';
|
||||
import {
|
||||
Collection,
|
||||
extractTypeFromDefinition,
|
||||
fieldTypeMap,
|
||||
Filter,
|
||||
InheritedCollection,
|
||||
JoiValidationError,
|
||||
UniqueConstraintError,
|
||||
} from '@nocobase/database';
|
||||
import PluginErrorHandler from '@nocobase/plugin-error-handler';
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import lodash from 'lodash';
|
||||
@@ -28,6 +36,9 @@ import { beforeDestoryField } from './hooks/beforeDestoryField';
|
||||
import { CollectionModel, FieldModel } from './models';
|
||||
import collectionActions from './resourcers/collections';
|
||||
import viewResourcer from './resourcers/views';
|
||||
import mainDataSourceResource from './resourcers/main-data-source';
|
||||
import { ColumnsDescription } from 'sequelize';
|
||||
import { PRESET_FIELDS_INTERFACES } from './constants';
|
||||
import { Schema } from '@formily/json-schema';
|
||||
import _ from 'lodash';
|
||||
|
||||
@@ -428,7 +439,7 @@ export class PluginDataSourceMainServer extends Plugin {
|
||||
|
||||
this.app.acl.registerSnippet({
|
||||
name: `pm.data-source-manager.data-source-main`,
|
||||
actions: ['collections:*', 'collections.fields:*', 'collectionCategories:*'],
|
||||
actions: ['collections:*', 'collections.fields:*', 'collectionCategories:*', 'mainDataSource:*'],
|
||||
});
|
||||
|
||||
this.app.acl.registerSnippet({
|
||||
@@ -470,11 +481,11 @@ export class PluginDataSourceMainServer extends Plugin {
|
||||
}
|
||||
await next();
|
||||
});
|
||||
this.app.resourceManager.define(viewResourcer);
|
||||
this.app.resourceManager.registerActionHandlers(collectionActions);
|
||||
this.app.resourceManager.define(mainDataSourceResource);
|
||||
|
||||
this.app.resource(viewResourcer);
|
||||
this.app.actions(collectionActions);
|
||||
|
||||
const handleFieldSource = (fields) => {
|
||||
const handleFieldSource = (fields, rawFields?: ColumnsDescription) => {
|
||||
for (const field of lodash.castArray(fields)) {
|
||||
if (field.get('source')) {
|
||||
const [collectionSource, fieldSource] = field.get('source').split('.');
|
||||
@@ -500,6 +511,15 @@ export class PluginDataSourceMainServer extends Plugin {
|
||||
// set final options
|
||||
field.set('options', newOptions);
|
||||
}
|
||||
const fieldTypes = fieldTypeMap[this.db.options.dialect];
|
||||
if (rawFields && fieldTypes) {
|
||||
const rawField = rawFields[field.get('name')];
|
||||
if (rawField && !PRESET_FIELDS_INTERFACES.includes(field.get('interface'))) {
|
||||
const mappedType = extractTypeFromDefinition(rawField.type);
|
||||
const possibleTypes = fieldTypes[mappedType];
|
||||
field.set('possibleTypes', possibleTypes);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -522,7 +542,19 @@ export class PluginDataSourceMainServer extends Plugin {
|
||||
|
||||
//handle collections:fields:list
|
||||
if (ctx.action.resourceName == 'collections.fields' && ctx.action.actionName == 'list') {
|
||||
handleFieldSource(ctx.action.params?.paginate == 'false' ? ctx.body : ctx.body.rows);
|
||||
const collectionName = ctx.action.sourceId;
|
||||
const collection: Collection = ctx.db.getCollection(collectionName);
|
||||
let rawFields: ColumnsDescription = {};
|
||||
if (collection) {
|
||||
try {
|
||||
rawFields = await ctx.app.db.queryInterface.sequelizeQueryInterface.describeTable(
|
||||
collection.getTableNameWithSchema(),
|
||||
);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
handleFieldSource(ctx.action.params?.paginate == 'false' ? ctx.body : ctx.body.rows, rawFields);
|
||||
}
|
||||
|
||||
if (ctx.action.resourceName == 'collections.fields' && ctx.action.actionName == 'get') {
|
||||
|
||||
+4
-3
@@ -21,11 +21,12 @@ export const BreadcumbTitle = () => {
|
||||
const { name } = useParams();
|
||||
const compile = useCompile();
|
||||
const dm = useDataSourceManager();
|
||||
const { displayName } = dm.getDataSource(name) || {};
|
||||
const dataSourceKey = name || 'main';
|
||||
const { displayName } = dm.getDataSource(dataSourceKey) || {};
|
||||
const { dataSource } = useContext(DataSourceContext);
|
||||
const dataSourceValue = useMemo(
|
||||
() => (dataSource && dataSource?.name === name ? dataSource : dm.getDataSource(name)),
|
||||
[dataSource, name],
|
||||
() => (dataSource && dataSource?.name === dataSourceKey ? dataSource : dm.getDataSource(dataSourceKey)),
|
||||
[dataSource, dataSourceKey, dm.getDataSource(dataSourceKey)?.status],
|
||||
);
|
||||
const items = useMemo(() => {
|
||||
const status = dataSourceValue?.status;
|
||||
|
||||
+11
-47
@@ -28,11 +28,11 @@ import {
|
||||
useRecord,
|
||||
} from '@nocobase/client';
|
||||
import { getPickerFormat } from '@nocobase/utils/client';
|
||||
import { message } from 'antd';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useContext, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DataSourceContext } from '../../DatabaseConnectionProvider';
|
||||
import { useDataSourceRefresh } from '../../hooks/useDataSourceRefresh';
|
||||
import { useDataSourceData } from '../../hooks/useResourceData';
|
||||
import { CollectionFields } from './CollectionFields';
|
||||
import { getCollectionSchema } from './schema/collections';
|
||||
|
||||
@@ -100,6 +100,8 @@ export const ConfigurationTable = () => {
|
||||
const data = useContext(CollectionCategoriesContext);
|
||||
const api = useAPIClient();
|
||||
const compile = useCompile();
|
||||
const service = useContext(ResourceActionContext);
|
||||
|
||||
const loadCategories = async () => {
|
||||
return data.data.map((item: any) => ({
|
||||
label: compile(item.name),
|
||||
@@ -122,56 +124,18 @@ export const ConfigurationTable = () => {
|
||||
};
|
||||
|
||||
const useRefreshActionProps = () => {
|
||||
const service = useContext(ResourceActionContext);
|
||||
const api = useAPIClient();
|
||||
const field = useField();
|
||||
const { name } = useParams();
|
||||
field.data = field.data || {};
|
||||
const { setDataSource, dataSource } = useContext(DataSourceContext);
|
||||
return {
|
||||
async onClick() {
|
||||
field.data.loading = true;
|
||||
try {
|
||||
const { data } = await api.request({
|
||||
url: `dataSources:refresh?filterByTk=${name}&clientStatus=${dataSource?.status || 'loaded'}`,
|
||||
method: 'post',
|
||||
});
|
||||
field.data.loading = false;
|
||||
setDataSource({ ...data?.data, name });
|
||||
if (data?.data?.status === 'reloading') {
|
||||
message.warning(t('Data source synchronization in progress'));
|
||||
} else if (data?.data?.status === 'loaded') {
|
||||
message.success(t('Data source synchronization successful'));
|
||||
service?.refresh?.();
|
||||
}
|
||||
await ds.getDataSource(name).reload();
|
||||
} catch (error) {
|
||||
field.data.loading = false;
|
||||
}
|
||||
return useDataSourceRefresh({
|
||||
dataSourceName: name,
|
||||
onSuccess: () => {
|
||||
service?.refresh?.();
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
const collectionSchema = useMemo(() => {
|
||||
return getCollectionSchema(name);
|
||||
}, [name]);
|
||||
|
||||
const resource = api.resource('dataSources', name);
|
||||
const [dataSourceData, setDataSourceData] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
resource
|
||||
.get({
|
||||
filterByTk: name,
|
||||
})
|
||||
.then((data) => {
|
||||
setDataSourceData(data?.data);
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, [name]);
|
||||
const { data: dataSourceData } = useDataSourceData(name);
|
||||
|
||||
const loadFilterTargetKeys = async (field) => {
|
||||
const { fields } = field.form.values;
|
||||
|
||||
+4
-1
@@ -147,8 +147,11 @@ export const useUpdateCollectionActionAndRefreshCM = (options) => {
|
||||
async run() {
|
||||
await form.submit();
|
||||
await api.request({
|
||||
url: `dataSources/${name}/collections:update?filterByTk=${filterByTk}`,
|
||||
url: `dataSources/${name}/collections:update`,
|
||||
method: 'post',
|
||||
params: {
|
||||
filterByTk,
|
||||
},
|
||||
data: {
|
||||
...omit(form.values, ['fields', 'autoGenId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'sortable']),
|
||||
},
|
||||
|
||||
+4
-2
@@ -91,16 +91,18 @@ export const SetFilterTargetKey = (props) => {
|
||||
}
|
||||
if (dataSourceKey === 'main') {
|
||||
await api.request({
|
||||
url: `collections:update?filterByTk=${record.name}`,
|
||||
url: `collections:update`,
|
||||
method: 'post',
|
||||
params: { filterByTk: record.name },
|
||||
data: {
|
||||
filterTargetKey,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await api.request({
|
||||
url: `dataSources/${dataSourceKey}/collections:update?filterByTk=${record.name}`,
|
||||
url: `dataSources/${dataSourceKey}/collections:update`,
|
||||
method: 'post',
|
||||
params: { filterByTk: record.name },
|
||||
data: {
|
||||
filterTargetKey,
|
||||
},
|
||||
|
||||
+2
-1
@@ -35,8 +35,9 @@ export const TitleField = withDynamicSchemaProps(
|
||||
const handleChange = async (checked) => {
|
||||
setLoadingRecord(record);
|
||||
await api.request({
|
||||
url: `dataSources/${dataSourceKey}/collections:update?filterByTk=${filterByTk}`,
|
||||
url: `dataSources/${dataSourceKey}/collections:update`,
|
||||
method: 'post',
|
||||
params: { filterByTk },
|
||||
data: { titleField: checked ? record.name : 'id' },
|
||||
});
|
||||
message.success(t('Saved successfully'));
|
||||
|
||||
+9
-9
@@ -153,15 +153,15 @@ const CollectionsTable = observer((tableProps: any) => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
isFirst: from === 'create',
|
||||
dbOptions: { ...options, type: formValues.type || 'mysql' },
|
||||
};
|
||||
|
||||
const response = await api.request({
|
||||
url: `dataSources/${key}/collections:all`,
|
||||
method: 'get',
|
||||
params,
|
||||
const response = await api.resource('dataSources').readTables({
|
||||
values: {
|
||||
dataSourceKey: key,
|
||||
...(from === 'create'
|
||||
? {
|
||||
dbOptions: { ...options, type: formValues.type || 'mysql' },
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const { data } = response?.data || {};
|
||||
const allCollectionsData = data || [];
|
||||
|
||||
+37
-1
@@ -9,6 +9,7 @@
|
||||
|
||||
import { DownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { uid } from '@formily/shared';
|
||||
import { useForm, useField } from '@formily/react';
|
||||
import {
|
||||
ActionContext,
|
||||
SchemaComponent,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
usePlugin,
|
||||
useResourceContext,
|
||||
useActionContext,
|
||||
useResourceActionContext,
|
||||
} from '@nocobase/client';
|
||||
import { Button, Dropdown, Empty } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
@@ -26,6 +28,39 @@ import { useLoadCollections, useTestConnectionAction } from '../hooks';
|
||||
import { NAMESPACE } from '../locale';
|
||||
import { CollectionsTableField } from './CollectionsTableField';
|
||||
|
||||
const useCreateAction = (actionCallback?: (values: any) => void) => {
|
||||
const form = useForm();
|
||||
const field = useField();
|
||||
const ctx = useActionContext();
|
||||
const { refresh } = useResourceActionContext();
|
||||
const { resource } = useResourceContext();
|
||||
return {
|
||||
async run() {
|
||||
try {
|
||||
await form.submit();
|
||||
field.data = field.data || {};
|
||||
field.data.loading = true;
|
||||
const collections: { name: string; selected: boolean }[] = form.values.collections || [];
|
||||
const res = await resource.create({
|
||||
values: {
|
||||
...form.values,
|
||||
collections: collections.filter((c) => c.selected).map((c) => c.name),
|
||||
},
|
||||
});
|
||||
ctx.setVisible(false);
|
||||
actionCallback?.(res?.data?.data);
|
||||
await form.reset();
|
||||
field.data.loading = false;
|
||||
refresh();
|
||||
} catch (error) {
|
||||
if (field.data) {
|
||||
field.data.loading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateDatabaseConnectAction = () => {
|
||||
const [schema, setSchema] = useState({});
|
||||
const plugin = usePlugin(PluginDatabaseConnectionsClient);
|
||||
@@ -108,7 +143,7 @@ export const CreateDatabaseConnectAction = () => {
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
useAction: '{{ cm.useCreateAction }}',
|
||||
useAction: '{{ useCreateAction }}',
|
||||
actionCallback: '{{ dataSourceCreateCallback }}',
|
||||
},
|
||||
},
|
||||
@@ -170,6 +205,7 @@ export const CreateDatabaseConnectAction = () => {
|
||||
useTestConnectionAction,
|
||||
dialect,
|
||||
useDialectDataSource,
|
||||
useCreateAction,
|
||||
}}
|
||||
schema={schema}
|
||||
/>
|
||||
|
||||
+12
-7
@@ -26,14 +26,21 @@ import { ThirdDataSource } from '../ThridDataSource';
|
||||
import { CreateDatabaseConnectAction } from './CreateDatabaseConnectAction';
|
||||
import { EditDatabaseConnectionAction } from './EditDatabaseConnectionAction';
|
||||
import { ViewDatabaseConnectionAction } from './ViewDatabaseConnectionAction';
|
||||
import { addDatasourceCollections } from '../hooks';
|
||||
import _ from 'lodash';
|
||||
|
||||
export const DatabaseConnectionManagerPane = () => {
|
||||
const { t } = useTranslation();
|
||||
const plugin = usePlugin(PluginDatabaseConnectionsClient);
|
||||
const dm = useDataSourceManager();
|
||||
const api = useAPIClient(); // 移到组件顶层
|
||||
const api = useAPIClient();
|
||||
|
||||
const dataSourceListCallback = useCallback(
|
||||
(data: any) => {
|
||||
dm.setDataSources(data?.data || []);
|
||||
dm.reload();
|
||||
},
|
||||
[dm],
|
||||
);
|
||||
|
||||
const types = [...plugin.types.keys()]
|
||||
.map((key) => {
|
||||
@@ -65,14 +72,11 @@ export const DatabaseConnectionManagerPane = () => {
|
||||
);
|
||||
|
||||
const dataSourceCreateCallback = useCallback(
|
||||
async (data: any, collections) => {
|
||||
if (!data.options?.addAllCollections) {
|
||||
await addDatasourceCollections(api, data.key, { collections, dbOptions: data.options });
|
||||
}
|
||||
async (data: any) => {
|
||||
dm.addDataSource(ThirdDataSource, data);
|
||||
reloadKeys.current = [...reloadKeys.current, data.key];
|
||||
},
|
||||
[api, dm],
|
||||
[dm],
|
||||
);
|
||||
|
||||
const useRefreshActionProps = () => {
|
||||
@@ -122,6 +126,7 @@ export const DatabaseConnectionManagerPane = () => {
|
||||
useDestroyAction,
|
||||
dataSourceDeleteCallback,
|
||||
dataSourceCreateCallback,
|
||||
dataSourceListCallback,
|
||||
useIsAbleDelete,
|
||||
}}
|
||||
schema={databaseConnectionSchema}
|
||||
|
||||
+10
-11
@@ -26,7 +26,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useForm, useField } from '@formily/react';
|
||||
import PluginDatabaseConnectionsClient from '../';
|
||||
import { NAMESPACE } from '../locale';
|
||||
import { addDatasourceCollections, useLoadCollections } from '../hooks';
|
||||
import { useLoadCollections } from '../hooks';
|
||||
import { CollectionsTableField } from './CollectionsTableField';
|
||||
|
||||
export const EditDatabaseConnectionAction = () => {
|
||||
@@ -52,18 +52,17 @@ export const EditDatabaseConnectionAction = () => {
|
||||
await form.submit();
|
||||
field.data = field.data || {};
|
||||
field.data.loading = true;
|
||||
const collections: { name: string; selected: boolean }[] = form.values.collections || [];
|
||||
try {
|
||||
await resource.update({ filterByTk, values: _.omit(form.values, 'collections') });
|
||||
const toBeAddedCollections = form.values.collections || [];
|
||||
if (!form.values.addAllCollections) {
|
||||
await addDatasourceCollections(api, filterByTk, {
|
||||
collections: toBeAddedCollections,
|
||||
dbOptions: form.values.options,
|
||||
});
|
||||
}
|
||||
delete form.values.collections;
|
||||
await resource.update({
|
||||
filterByTk,
|
||||
values: {
|
||||
...form.values,
|
||||
collections: collections.filter((c) => c.selected).map((c) => c.name),
|
||||
},
|
||||
});
|
||||
ctx.setVisible(false);
|
||||
dm.getDataSource(filterByTk).setOptions(form.values);
|
||||
dm.getDataSource(filterByTk).setOptions(_.omit(form.values, 'collections'));
|
||||
dm.getDataSource(filterByTk).reload();
|
||||
await form.reset();
|
||||
refresh();
|
||||
|
||||
+393
-83
@@ -34,13 +34,17 @@ import {
|
||||
useResourceActionContext,
|
||||
useResourceContext,
|
||||
ViewCollectionField,
|
||||
useFieldInterfaceOptions,
|
||||
useApp,
|
||||
} from '@nocobase/client';
|
||||
import { message, Space, Switch, Table, TableColumnProps, Tag, Tooltip } from 'antd';
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { message, Select, Space, Switch, Table, TableColumnProps, Tag, Tooltip } from 'antd';
|
||||
import React, { createContext, useContext, useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { FilterTargetKeyAlert } from '../../CollectionsManager/FilterTargetKeyAlert';
|
||||
import { collection } from './schemas/collectionFields';
|
||||
import { SyncFieldChangesAction } from './db-sync/SyncFieldChangesAction';
|
||||
const resourceActionProps = {
|
||||
association: {
|
||||
sourceKey: 'name',
|
||||
@@ -70,24 +74,56 @@ const CollectionFieldsProvider = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const indentStyle = css`
|
||||
.ant-table {
|
||||
margin-left: -16px !important;
|
||||
}
|
||||
`;
|
||||
const tableContainer = css`
|
||||
tr {
|
||||
display: flex;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
flex: 2.3;
|
||||
flex: 2;
|
||||
width: 0;
|
||||
&:nth-child(5) {
|
||||
flex: 1.2;
|
||||
min-width: 120px;
|
||||
|
||||
/* Field display name */
|
||||
&:nth-child(2) {
|
||||
flex: 2.5;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Field name */
|
||||
&:nth-child(3) {
|
||||
flex: 2;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Field interface - 需要更多空间 */
|
||||
&:nth-child(4) {
|
||||
flex: 2.5;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* Field type */
|
||||
&:nth-child(5) {
|
||||
flex: 1.8;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Title field */
|
||||
&:nth-child(6) {
|
||||
flex: 1.2;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
&:nth-child(7) {
|
||||
flex: 2;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
&:last-child {
|
||||
flex: 1.5;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
.ant-table-selection-column,
|
||||
@@ -98,13 +134,262 @@ const tableContainer = css`
|
||||
}
|
||||
`;
|
||||
|
||||
const groupTableContainer = css`
|
||||
tr {
|
||||
display: flex;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
flex: 2;
|
||||
width: 0;
|
||||
min-width: 120px;
|
||||
|
||||
/* Title column for inherited fields - 需要更多空间显示完整标题 */
|
||||
&:nth-child(2) {
|
||||
flex: 4;
|
||||
min-width: 300px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Field display name */
|
||||
&:nth-child(3) {
|
||||
flex: 2.5;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Field name */
|
||||
&:nth-child(4) {
|
||||
flex: 2;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Field interface */
|
||||
&:nth-child(5) {
|
||||
flex: 2.5;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* Field type */
|
||||
&:nth-child(6) {
|
||||
flex: 1.8;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Title field */
|
||||
&:nth-child(7) {
|
||||
flex: 1.2;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
&:last-child {
|
||||
flex: 1.5;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
.ant-table-selection-column,
|
||||
.ant-table-row-expand-icon-cell {
|
||||
flex-basis: 50px !important;
|
||||
min-width: 50px;
|
||||
flex: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const inheritFieldsContainer = css`
|
||||
tr {
|
||||
display: flex;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
flex: 2;
|
||||
width: 0;
|
||||
min-width: 100px;
|
||||
|
||||
/* Field display name */
|
||||
&:nth-child(1) {
|
||||
flex: 2.5;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Field name - 减少宽度 */
|
||||
&:nth-child(2) {
|
||||
flex: 1.5;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Field interface */
|
||||
&:nth-child(3) {
|
||||
flex: 2;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Field type */
|
||||
&:nth-child(4) {
|
||||
flex: 1.5;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Title field */
|
||||
&:nth-child(5) {
|
||||
flex: 1.2;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
&:nth-child(6) {
|
||||
flex: 2;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
&:last-child {
|
||||
flex: 1.8;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const titlePrompt = 'Default title for each record';
|
||||
|
||||
const getInterfaceOptions = (data, type) => {
|
||||
const interfaceOptions = [];
|
||||
data.forEach((item) => {
|
||||
const options = item.children.filter((h) => h?.availableTypes?.includes(type));
|
||||
interfaceOptions.push({
|
||||
label: item.label,
|
||||
key: item.key,
|
||||
children: options,
|
||||
});
|
||||
});
|
||||
return interfaceOptions.filter((v) => {
|
||||
if (type === 'sort') {
|
||||
return v.key === 'advanced';
|
||||
}
|
||||
return v.children.length > 0;
|
||||
});
|
||||
};
|
||||
|
||||
const isValueInOptions = (value, options) => {
|
||||
return options?.some((option) => option.children?.some?.((child) => child.name === value));
|
||||
};
|
||||
|
||||
const FieldInterfaceRenderer = ({ value, record, updateFieldHandler, isPresetField = false }) => {
|
||||
const compile = useCompile();
|
||||
const { getInterface } = useCollectionManager_deprecated();
|
||||
const initOptions = useFieldInterfaceOptions();
|
||||
const [selectValue, setSelectValue] = useState(value);
|
||||
const [options, setOptions] = useState([]);
|
||||
const targetType = record.type;
|
||||
|
||||
const applyInterfaceChange = useCallback(
|
||||
async (newValue) => {
|
||||
const interfaceConfig = getInterface(newValue);
|
||||
setSelectValue(newValue);
|
||||
await updateFieldHandler(record, {
|
||||
interface: newValue,
|
||||
uiSchema: {
|
||||
title: record?.uiSchema?.title,
|
||||
...interfaceConfig?.default?.uiSchema,
|
||||
},
|
||||
});
|
||||
},
|
||||
[record],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPresetField) return;
|
||||
if (record?.possibleTypes || targetType) {
|
||||
const newOptions = getInterfaceOptions(initOptions, targetType);
|
||||
setOptions(newOptions);
|
||||
}
|
||||
}, [record, isPresetField, targetType, initOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPresetField) return;
|
||||
if (!options.length) return;
|
||||
|
||||
const firstOption = options[0]?.children?.[0]?.name;
|
||||
if (!firstOption) return;
|
||||
|
||||
if (
|
||||
(options.length === 1 && options[0]?.children?.length === 1 && selectValue !== firstOption) ||
|
||||
(selectValue && !isValueInOptions(selectValue, options))
|
||||
) {
|
||||
applyInterfaceChange(firstOption);
|
||||
}
|
||||
}, [selectValue, isPresetField, options, applyInterfaceChange]);
|
||||
|
||||
if (['oho', 'obo', 'o2m', 'm2o', 'm2m'].includes(record.interface)) {
|
||||
return (
|
||||
<Tag>
|
||||
{compile(initOptions.find((h) => h.key === 'relation')?.children?.find((v) => v.name === value)?.label)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.length || options.every((group) => !group.children.length)) {
|
||||
return <Tag>{compile(getInterface(value)?.title)}</Tag>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={`field-interface-${record?.interface}`}
|
||||
value={selectValue}
|
||||
style={{ width: '100%' }}
|
||||
popupMatchSelectWidth={false}
|
||||
disabled={isPresetField}
|
||||
onChange={async (newValue) => {
|
||||
if (isPresetField) return;
|
||||
const interfaceConfig = getInterface(newValue);
|
||||
setSelectValue(newValue);
|
||||
await updateFieldHandler(record, {
|
||||
interface: newValue,
|
||||
uiSchema: { title: record?.uiSchema?.title, ...interfaceConfig?.default?.uiSchema },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{options.map((group) => (
|
||||
<Select.OptGroup key={group.key} label={compile(group.label)}>
|
||||
{group.children.map((item) => (
|
||||
<Select.Option key={item.name} value={item.name}>
|
||||
{compile(item.label)}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
const FieldTypeRenderer = ({ value, record, updateFieldHandler, isPresetField = false }) => {
|
||||
const item = omit(record, ['__parent', '__collectionName']);
|
||||
const possibleTypes = item?.possibleTypes || [];
|
||||
return !Array.isArray(item?.possibleTypes) || isPresetField || !possibleTypes.includes(value) ? (
|
||||
<Tag>{value}</Tag>
|
||||
) : (
|
||||
<Select
|
||||
aria-label={`field-type-${value}`}
|
||||
defaultValue={value}
|
||||
popupMatchSelectWidth={false}
|
||||
style={{ width: '100%' }}
|
||||
options={
|
||||
item?.possibleTypes.map((v) => {
|
||||
return { label: v, value: v };
|
||||
}) || []
|
||||
}
|
||||
onChange={async (newValue) => {
|
||||
await updateFieldHandler(record, { type: newValue });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CurrentFields = (props) => {
|
||||
const compile = useCompile();
|
||||
const { getInterface } = useCollectionManager_deprecated();
|
||||
const { t } = useTranslation();
|
||||
const { setState } = useResourceActionContext();
|
||||
const { targetKey } = props.collectionResource || {};
|
||||
const { targetKey, resource } = props.collectionResource || {};
|
||||
const parentRecordData = useRecord();
|
||||
const [loadingRecord, setLoadingRecord] = React.useState<any>(null);
|
||||
const { refreshCM, isTitleField, getTemplate } = useCollectionManager_deprecated();
|
||||
@@ -112,6 +397,30 @@ const CurrentFields = (props) => {
|
||||
const targetTemplate = getTemplate(template);
|
||||
const api = useAPIClient();
|
||||
const ctx = useContext(CollectionListContext);
|
||||
const { collectionInfo } = props;
|
||||
const app = useApp();
|
||||
const mainDataSourcePlugin: any = app.pm.get('data-source-main');
|
||||
const collectionPresetFields = mainDataSourcePlugin.getCollectionPresetFields();
|
||||
const collectionPresetFieldsInterfaces = collectionPresetFields.map((v) => v.value?.interface).filter((v) => v);
|
||||
|
||||
const updateFieldHandler = async (record, values) => {
|
||||
try {
|
||||
await resource.update({
|
||||
filterByTk: record.name,
|
||||
values: {
|
||||
...values,
|
||||
collectionName: record.collectionName,
|
||||
},
|
||||
});
|
||||
// ctx?.refresh?.();
|
||||
await props.refreshAsync();
|
||||
message.success(t('Saved successfully'));
|
||||
refreshCM();
|
||||
} catch (error) {
|
||||
console.error('Failed to update field type:', error);
|
||||
message.error(t('Save failed'));
|
||||
}
|
||||
};
|
||||
const columns: TableColumnProps<any>[] = [
|
||||
{
|
||||
dataIndex: ['uiSchema', 'title'],
|
||||
@@ -125,7 +434,26 @@ const CurrentFields = (props) => {
|
||||
{
|
||||
dataIndex: 'interface',
|
||||
title: t('Field interface'),
|
||||
render: (value) => <Tag>{compile(getInterface(value)?.title)}</Tag>,
|
||||
render: (value, record) => (
|
||||
<FieldInterfaceRenderer
|
||||
value={value}
|
||||
record={record}
|
||||
updateFieldHandler={updateFieldHandler}
|
||||
isPresetField={collectionInfo.from === 'db2cm' || collectionPresetFieldsInterfaces.includes(record.interface)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
dataIndex: 'type',
|
||||
title: t('Field type'),
|
||||
render: (value, record) => (
|
||||
<FieldTypeRenderer
|
||||
value={value}
|
||||
record={record}
|
||||
updateFieldHandler={updateFieldHandler}
|
||||
isPresetField={collectionInfo.from === 'db2cm' || collectionPresetFieldsInterfaces.includes(record.interface)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
dataIndex: 'titleField',
|
||||
@@ -134,8 +462,9 @@ const CurrentFields = (props) => {
|
||||
const handleChange = async (checked) => {
|
||||
setLoadingRecord(record);
|
||||
await api.request({
|
||||
url: `collections:update?filterByTk=${filterByTk}`,
|
||||
url: `collections:update`,
|
||||
method: 'post',
|
||||
params: { filterByTk },
|
||||
data: { titleField: checked ? record.name : 'id' },
|
||||
});
|
||||
ctx?.refresh?.();
|
||||
@@ -160,7 +489,7 @@ const CurrentFields = (props) => {
|
||||
},
|
||||
{
|
||||
dataIndex: 'description',
|
||||
title: t('Descriptio '),
|
||||
title: t('Description'),
|
||||
render: (value) => <Input.ReadPretty value={value} ellipsis={true} />,
|
||||
},
|
||||
{
|
||||
@@ -192,9 +521,10 @@ const CurrentFields = (props) => {
|
||||
<Table
|
||||
rowKey={'name'}
|
||||
columns={columns}
|
||||
showHeader={false}
|
||||
showHeader={true}
|
||||
pagination={false}
|
||||
dataSource={props.fields}
|
||||
className={tableContainer}
|
||||
rowSelection={{
|
||||
type: 'checkbox',
|
||||
// @ts-ignore
|
||||
@@ -212,7 +542,6 @@ const CurrentFields = (props) => {
|
||||
});
|
||||
},
|
||||
}}
|
||||
className={indentStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -244,6 +573,11 @@ const InheritFields = (props) => {
|
||||
title: t('Field interface'),
|
||||
render: (value) => <Tag>{compile(getInterface(value)?.title)}</Tag>,
|
||||
},
|
||||
{
|
||||
dataIndex: 'type',
|
||||
title: t('Field type'),
|
||||
render: (value, record) => <Tag>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
dataIndex: 'titleField',
|
||||
title: t('Title field'),
|
||||
@@ -252,8 +586,9 @@ const InheritFields = (props) => {
|
||||
setLoadingRecord(record);
|
||||
|
||||
await api.request({
|
||||
url: `collections:update?filterByTk=${filterByTk}`,
|
||||
url: `collections:update`,
|
||||
method: 'post',
|
||||
params: { filterByTk },
|
||||
data: { titleField: checked ? record.name : 'id' },
|
||||
});
|
||||
ctx?.refresh?.();
|
||||
@@ -275,6 +610,11 @@ const InheritFields = (props) => {
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'description',
|
||||
title: t('Description'),
|
||||
render: (value) => <Input.ReadPretty value={value} ellipsis={true} />,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: t('Actions'),
|
||||
@@ -305,6 +645,7 @@ const InheritFields = (props) => {
|
||||
showHeader={false}
|
||||
pagination={false}
|
||||
dataSource={props.fields.filter((field) => field.interface)}
|
||||
className={inheritFieldsContainer}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -335,6 +676,8 @@ const CollectionFieldsInternal = () => {
|
||||
<div
|
||||
className={css`
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
min-width: 300px;
|
||||
`}
|
||||
>
|
||||
{value}
|
||||
@@ -350,12 +693,12 @@ const CollectionFieldsInternal = () => {
|
||||
title: t('Field interface'),
|
||||
},
|
||||
{
|
||||
dataIndex: 'titleField',
|
||||
title: t('Title field'),
|
||||
dataIndex: 'type',
|
||||
title: t('Field type'),
|
||||
},
|
||||
{
|
||||
dataIndex: 'description',
|
||||
title: t('Description'),
|
||||
dataIndex: 'titleField',
|
||||
title: t('Title field'),
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
@@ -363,50 +706,10 @@ const CollectionFieldsInternal = () => {
|
||||
},
|
||||
];
|
||||
const fields = data?.data || [];
|
||||
const groups = {
|
||||
pf: [],
|
||||
association: [],
|
||||
general: [],
|
||||
system: [],
|
||||
};
|
||||
const allCurrentFields = fields.filter((field) => field.interface || field.primaryKey || field.isForeignKey);
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (field.primaryKey || field.isForeignKey) {
|
||||
groups.pf.push(field);
|
||||
} else if (field.interface) {
|
||||
const conf = getInterface(field.interface);
|
||||
if (conf?.group === 'systemInfo') {
|
||||
groups.system.push(field);
|
||||
} else if (conf?.group === 'relation') {
|
||||
groups.association.push(field);
|
||||
} else {
|
||||
groups.general.push(field);
|
||||
}
|
||||
}
|
||||
});
|
||||
const dataSource = [];
|
||||
|
||||
const dataSource = [
|
||||
{
|
||||
key: 'pf',
|
||||
title: t('PK & FK fields'),
|
||||
fields: groups.pf,
|
||||
},
|
||||
{
|
||||
key: 'association',
|
||||
title: t('Association fields'),
|
||||
fields: groups.association,
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
title: t('General fields'),
|
||||
fields: groups.general,
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
title: t('System fields'),
|
||||
fields: groups.system,
|
||||
},
|
||||
];
|
||||
dataSource.push(
|
||||
...inherits
|
||||
.map((key) => {
|
||||
@@ -454,6 +757,7 @@ const CollectionFieldsInternal = () => {
|
||||
<Action {...deleteProps} />
|
||||
<SyncFieldsAction {...syncProps} />
|
||||
<SyncSQLFieldsAction refreshCMList={refreshAsync} />
|
||||
<SyncFieldChangesAction />
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'object',
|
||||
@@ -464,32 +768,38 @@ const CollectionFieldsInternal = () => {
|
||||
/>
|
||||
<AddCollectionField {...addProps} />
|
||||
</Space>
|
||||
<Table
|
||||
rowKey={'key'}
|
||||
columns={columns}
|
||||
dataSource={dataSource.filter((d) => d.fields.length)}
|
||||
pagination={false}
|
||||
className={tableContainer}
|
||||
expandable={{
|
||||
defaultExpandAllRows: true,
|
||||
defaultExpandedRowKeys: dataSource.map((d) => d.key),
|
||||
expandedRowRender: (record) =>
|
||||
record.inherit ? (
|
||||
{allCurrentFields.length > 0 && (
|
||||
<div>
|
||||
<CurrentFields
|
||||
fields={allCurrentFields}
|
||||
collectionInfo={field.record}
|
||||
collectionResource={collectionResource}
|
||||
refreshAsync={refreshAsync}
|
||||
type="all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{dataSource.filter((d) => d.fields.length).length > 0 && (
|
||||
<Table
|
||||
rowKey={'key'}
|
||||
columns={columns}
|
||||
dataSource={dataSource.filter((d) => d.fields.length)}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className={groupTableContainer}
|
||||
expandable={{
|
||||
defaultExpandAllRows: true,
|
||||
defaultExpandedRowKeys: dataSource.map((d) => d.key),
|
||||
expandedRowRender: (record) => (
|
||||
<InheritFields
|
||||
fields={record.fields}
|
||||
collectionResource={collectionResource}
|
||||
refreshAsync={refreshAsync}
|
||||
/>
|
||||
) : (
|
||||
<CurrentFields
|
||||
fields={record.fields}
|
||||
collectionResource={collectionResource}
|
||||
refreshAsync={refreshAsync}
|
||||
type={record.key}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FieldContext.Provider>
|
||||
</FormContext.Provider>
|
||||
);
|
||||
|
||||
+15
@@ -16,6 +16,7 @@ import {
|
||||
DataSourceContext_deprecated,
|
||||
EditSubFieldAction,
|
||||
FieldSummary,
|
||||
ResourceActionContext,
|
||||
SchemaComponent,
|
||||
SchemaComponentContext,
|
||||
TemplateSummary,
|
||||
@@ -32,6 +33,8 @@ import React, { useContext, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CollectionFields } from './CollectionFields';
|
||||
import { collectionSchema } from './schemas/collections';
|
||||
import { useDataSourceRefresh } from '../../../hooks';
|
||||
import { SyncFromDatabaseAction } from './db-sync/SyncFromDatabaseAction';
|
||||
|
||||
/**
|
||||
* @param service
|
||||
@@ -114,6 +117,16 @@ export const ConfigurationTable = () => {
|
||||
const compile = useCompile();
|
||||
const form = useForm();
|
||||
const app = useApp();
|
||||
const service = useContext(ResourceActionContext);
|
||||
|
||||
const useRefreshActionProps = () => {
|
||||
return useDataSourceRefresh({
|
||||
dataSourceName: 'main',
|
||||
onSuccess: () => {
|
||||
service?.refresh?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -224,6 +237,7 @@ export const ConfigurationTable = () => {
|
||||
FieldSummary,
|
||||
TemplateSummay: TemplateSummary,
|
||||
CollectionFields,
|
||||
SyncFromDatabaseAction,
|
||||
}}
|
||||
scope={{
|
||||
loadFilterTargetKeys,
|
||||
@@ -242,6 +256,7 @@ export const ConfigurationTable = () => {
|
||||
enableInherits: database?.dialect === 'postgres',
|
||||
isPG: database?.dialect === 'postgres',
|
||||
getPickerFormat,
|
||||
useRefreshActionProps,
|
||||
}}
|
||||
/>
|
||||
</SchemaComponentContext.Provider>
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Button, Drawer, Transfer, Input, message, App, Spin } from 'antd';
|
||||
import { useAPIClient, useDataSource } from '@nocobase/client';
|
||||
import { useResourceActionContext, useResourceContext } from '@nocobase/client';
|
||||
import { useDSMTranslation } from '../../../../locale';
|
||||
|
||||
interface CollectionItem {
|
||||
key: string;
|
||||
title: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface LoadCollectionProps {
|
||||
type?: 'default' | 'primary' | 'dashed' | 'link' | 'text';
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const LoadCollection: React.FC<LoadCollectionProps> = (props) => {
|
||||
const { ...restProps } = props;
|
||||
const { t } = useDSMTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [targetKeys, setTargetKeys] = useState<string[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [availableCollections, setAvailableCollections] = useState<CollectionItem[]>([]);
|
||||
const api = useAPIClient();
|
||||
const { resource } = useResourceContext();
|
||||
const { refresh } = useResourceActionContext();
|
||||
const { modal } = App.useApp();
|
||||
const ds = useDataSource();
|
||||
|
||||
const getSelectableCollections = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.resource('dataSources').readTables({
|
||||
values: { dataSourceKey: 'main' },
|
||||
});
|
||||
const collections = response.data?.data || [];
|
||||
return collections.map((item) => ({
|
||||
key: item.name,
|
||||
name: item.name,
|
||||
title: item.title || item.name,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch selectable tables:', error);
|
||||
return [];
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
const initializeData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const selectableCollections = await getSelectableCollections();
|
||||
setAvailableCollections(selectableCollections);
|
||||
setTargetKeys([]);
|
||||
} catch (error) {
|
||||
message.error(t('Failed to initialize data'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getSelectableCollections, t]);
|
||||
|
||||
const handleSearch = useCallback((direction: 'left' | 'right', value: string) => {
|
||||
setSearchValue(value);
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback((newTargetKeys: string[]) => {
|
||||
setTargetKeys(newTargetKeys);
|
||||
}, []);
|
||||
|
||||
const handleSelectChange = useCallback((sourceSelectedKeys: string[], targetSelectedKeys: string[]) => {
|
||||
setSelectedKeys([...sourceSelectedKeys, ...targetSelectedKeys]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (targetKeys.length === 0) {
|
||||
message.warning(t('Please select at least one table'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
modal.confirm({
|
||||
title: t('Confirm load tables'),
|
||||
content: t('Are you sure you want to load {{count}} table(s)?', { count: targetKeys.length }),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.resource('dataSources').loadTables({
|
||||
values: {
|
||||
dataSourceKey: 'main',
|
||||
tables: targetKeys,
|
||||
},
|
||||
});
|
||||
|
||||
message.success(t('Tables loaded successfully'));
|
||||
setOpen(false);
|
||||
setTargetKeys([]);
|
||||
setSelectedKeys([]);
|
||||
setSearchValue('');
|
||||
refresh();
|
||||
ds.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to load tables:', error);
|
||||
message.error(t('Failed to load tables'));
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
message.error(t('Failed to load tables'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetKeys, api, t, modal, refresh]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setOpen(false);
|
||||
setTargetKeys([]);
|
||||
setSelectedKeys([]);
|
||||
setSearchValue('');
|
||||
}, []);
|
||||
|
||||
const showDrawer = useCallback(() => {
|
||||
setOpen(true);
|
||||
initializeData();
|
||||
}, [initializeData]);
|
||||
|
||||
const filteredDataSource = useMemo(() => {
|
||||
if (!searchValue) return availableCollections;
|
||||
|
||||
return availableCollections.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
item.name.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
(item.description && item.description.toLowerCase().includes(searchValue.toLowerCase())),
|
||||
);
|
||||
}, [availableCollections, searchValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={showDrawer} {...restProps}>
|
||||
{t('Load tables from database')}
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={t('Load tables from database')}
|
||||
placement="right"
|
||||
onClose={handleCancel}
|
||||
open={open}
|
||||
width={800}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button onClick={handleCancel} style={{ marginRight: 8 }}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={loading} disabled={targetKeys.length === 0}>
|
||||
{t('Submit')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ height: 'calc(100vh - 160px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Spin spinning={loading} style={{ height: '100%' }}>
|
||||
<Transfer
|
||||
dataSource={filteredDataSource}
|
||||
titles={[t('Available tables'), t('Selected tables')]}
|
||||
targetKeys={targetKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
onChange={handleChange}
|
||||
onSelectChange={handleSelectChange}
|
||||
onSearch={handleSearch}
|
||||
render={(item) => item.title}
|
||||
showSearch
|
||||
style={{ height: '100%' }}
|
||||
listStyle={{
|
||||
width: 350,
|
||||
height: 'calc(100vh - 100px)',
|
||||
}}
|
||||
locale={{
|
||||
itemUnit: t('item'),
|
||||
itemsUnit: t('items'),
|
||||
searchPlaceholder: t('Search collections...'),
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadCollection;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useAPIClient, useCollectionRecordData, useDataSource, useResourceActionContext } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import { Button, message } from 'antd';
|
||||
import { useDSMTranslation } from '../../../../locale';
|
||||
|
||||
export const SyncFieldChangesAction: React.FC = () => {
|
||||
const { t } = useDSMTranslation();
|
||||
const api = useAPIClient();
|
||||
const record = useCollectionRecordData();
|
||||
const ds = useDataSource();
|
||||
const { refresh } = useResourceActionContext();
|
||||
|
||||
if (record.from === 'db2cm' || record.template === 'view' || record.template === 'sql') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={async () => {
|
||||
await api.resource('mainDataSource').syncFields({
|
||||
values: {
|
||||
collections: [record.name],
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
ds.reload();
|
||||
message.success(t('Sync successfully'));
|
||||
}}
|
||||
>
|
||||
{t('Sync from database')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Dropdown, Button, App, message } from 'antd';
|
||||
import { ImportOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import LoadCollection from './LoadCollectionAction';
|
||||
import { useAPIClient, useDataSource } from '@nocobase/client';
|
||||
import { useDSMTranslation } from '../../../../locale';
|
||||
|
||||
export const SyncFromDatabaseAction: React.FC = () => {
|
||||
const { t } = useDSMTranslation();
|
||||
const api = useAPIClient();
|
||||
const { modal } = App.useApp();
|
||||
const ds = useDataSource();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'load-tables',
|
||||
label: <LoadCollection />,
|
||||
icon: <ImportOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'sync-fields',
|
||||
label: t('Sync field changes from database'),
|
||||
icon: <ReloadOutlined />,
|
||||
onClick: async () => {
|
||||
modal.confirm({
|
||||
title: t('Sync field changes from database'),
|
||||
content: t('Field synchronization confirmation prompt'),
|
||||
onOk: async () => {
|
||||
await api.resource('mainDataSource').syncFields();
|
||||
ds.reload();
|
||||
message.success(t('Sync successfully'));
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button icon={<SyncOutlined />}>{t('Sync from database')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
+13
@@ -159,6 +159,15 @@ export const collectionTableSchema: ISchema = {
|
||||
},
|
||||
'x-align': 'left',
|
||||
},
|
||||
// refresh: {
|
||||
// type: 'void',
|
||||
// title: '{{ t("Refresh") }}',
|
||||
// 'x-component': 'Action',
|
||||
// 'x-use-component-props': 'useRefreshActionProps',
|
||||
// 'x-component-props': {
|
||||
// icon: 'ReloadOutlined',
|
||||
// },
|
||||
// },
|
||||
delete: {
|
||||
type: 'void',
|
||||
title: '{{ t("Delete") }}',
|
||||
@@ -168,6 +177,10 @@ export const collectionTableSchema: ISchema = {
|
||||
isBulk: true,
|
||||
},
|
||||
},
|
||||
syncFromDatabase: {
|
||||
type: 'void',
|
||||
'x-component': 'SyncFromDatabaseAction',
|
||||
},
|
||||
create: {
|
||||
type: 'void',
|
||||
title: '{{ t("Create collection") }}',
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './useDataSourceRefresh';
|
||||
export * from './useResourceData';
|
||||
export * from './useDataSourceActions';
|
||||
|
||||
import { useForm, useField } from '@formily/react';
|
||||
import { message } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -72,31 +76,12 @@ export const useTestConnectionAction = () => {
|
||||
export const useLoadCollections = () => {
|
||||
const api = useAPIClient();
|
||||
return async (key) => {
|
||||
const { data } = await api.request({
|
||||
url: `dataSources/${key}/collections:all`,
|
||||
method: 'get',
|
||||
const { data } = await api.resource('dataSources').readTables({
|
||||
values: {
|
||||
dataSourceKey: key,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
};
|
||||
|
||||
export const addDatasourceCollections = async (api, filterByTk, options: { collections; dbOptions }) => {
|
||||
const url = `dataSources/${filterByTk}/collections:add`;
|
||||
const { collections: toBeAddedCollections, dbOptions } = options;
|
||||
if (toBeAddedCollections.length) {
|
||||
const collections = [];
|
||||
for (const { name, selected } of toBeAddedCollections) {
|
||||
if (selected) {
|
||||
collections.push(name);
|
||||
}
|
||||
}
|
||||
await api.request({
|
||||
url,
|
||||
method: 'post',
|
||||
data: {
|
||||
dbOptions,
|
||||
collections,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useAPIClient, useDataSourceManager, useResourceActionContext } from '@nocobase/client';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
export const useDataSourceActions = () => {
|
||||
const dm = useDataSourceManager();
|
||||
const api = useAPIClient();
|
||||
const { data } = useResourceActionContext();
|
||||
|
||||
const handleDataSourcesUpdate = useCallback(
|
||||
(dataSources: any[]) => {
|
||||
dataSources?.forEach((dataSourceConfig) => {
|
||||
const dataSource = dm.getDataSource(dataSourceConfig.key);
|
||||
if (dataSource) {
|
||||
// TODO: Implement data source update logic
|
||||
}
|
||||
});
|
||||
},
|
||||
[dm],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.data) {
|
||||
handleDataSourcesUpdate(data.data);
|
||||
}
|
||||
}, [data, handleDataSourcesUpdate]);
|
||||
|
||||
const actions = {
|
||||
reloadAllDataSources: useCallback(async () => {
|
||||
const dataSources = dm.getDataSources();
|
||||
await Promise.all(dataSources.map((ds) => ds.reload()));
|
||||
}, [dm]),
|
||||
|
||||
reloadDataSource: useCallback(
|
||||
async (key: string) => {
|
||||
const dataSource = dm.getDataSource(key);
|
||||
if (dataSource) {
|
||||
await dataSource.reload();
|
||||
}
|
||||
},
|
||||
[dm],
|
||||
),
|
||||
|
||||
getDataSourceCollections: useCallback(
|
||||
(key: string) => {
|
||||
const dataSource = dm.getDataSource(key);
|
||||
return dataSource?.collectionManager.getCollections() || [];
|
||||
},
|
||||
[dm],
|
||||
),
|
||||
|
||||
testConnection: useCallback(
|
||||
async (key: string) => {
|
||||
try {
|
||||
await api.request({
|
||||
resource: 'dataSources',
|
||||
action: 'testConnection',
|
||||
params: { values: { key } },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to test connection for ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[api],
|
||||
),
|
||||
};
|
||||
|
||||
return actions;
|
||||
};
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useField } from '@formily/react';
|
||||
import { ResourceActionContext, useAPIClient, useDataSourceManager } from '@nocobase/client';
|
||||
import { message } from 'antd';
|
||||
import { useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DataSourceContext } from '../DatabaseConnectionProvider';
|
||||
|
||||
export interface UseDataSourceRefreshOptions {
|
||||
dataSourceName: string;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: any) => void;
|
||||
showMessage?: boolean;
|
||||
}
|
||||
|
||||
export const useDataSourceRefresh = (options: UseDataSourceRefreshOptions) => {
|
||||
const { dataSourceName, onSuccess, onError, showMessage = true } = options;
|
||||
const { t } = useTranslation();
|
||||
const service = useContext(ResourceActionContext);
|
||||
const api = useAPIClient();
|
||||
const field = useField();
|
||||
const ds = useDataSourceManager();
|
||||
const { setDataSource, dataSource } = useContext(DataSourceContext);
|
||||
|
||||
field.data = field.data || {};
|
||||
|
||||
return {
|
||||
async onClick() {
|
||||
field.data.loading = true;
|
||||
try {
|
||||
const { data } = await api.request({
|
||||
url: `${
|
||||
dataSourceName === 'main' ? 'mainDataSource' : 'dataSources'
|
||||
}:refresh?filterByTk=${dataSourceName}&clientStatus=${dataSource?.status || 'loaded'}`,
|
||||
method: 'post',
|
||||
});
|
||||
|
||||
field.data.loading = false;
|
||||
setDataSource({ ...data?.data, name: dataSourceName });
|
||||
|
||||
if (data?.data?.status === 'reloading') {
|
||||
if (showMessage) {
|
||||
message.warning(t('Data source synchronization in progress'));
|
||||
}
|
||||
} else if (data?.data?.status === 'loaded') {
|
||||
if (showMessage) {
|
||||
message.success(t('Data source synchronization successful'));
|
||||
}
|
||||
service?.refresh?.();
|
||||
}
|
||||
|
||||
await ds.getDataSource(dataSourceName).reload();
|
||||
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
field.data.loading = false;
|
||||
|
||||
if (showMessage) {
|
||||
message.error(t('Data source synchronization failed'));
|
||||
}
|
||||
|
||||
onError?.(error);
|
||||
}
|
||||
},
|
||||
|
||||
get loading() {
|
||||
return field.data.loading;
|
||||
},
|
||||
};
|
||||
};
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useAPIClient } from '@nocobase/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface UseResourceDataOptions {
|
||||
resourceName: string;
|
||||
resourceParams?: string | string[];
|
||||
getParams?: Record<string, any>;
|
||||
deps?: any[];
|
||||
enabled?: boolean;
|
||||
onSuccess?: (data: any) => void;
|
||||
onError?: (error: any) => void;
|
||||
}
|
||||
|
||||
export const useResourceData = (options: UseResourceDataOptions) => {
|
||||
const { resourceName, resourceParams, getParams = {}, deps = [], enabled = true, onSuccess, onError } = options;
|
||||
|
||||
const api = useAPIClient();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<any>(null);
|
||||
|
||||
const resource = resourceParams ? api.resource(resourceName, resourceParams) : api.resource(resourceName);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!enabled) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await resource.get(getParams);
|
||||
const responseData = response?.data;
|
||||
|
||||
setData(responseData);
|
||||
onSuccess?.(responseData);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
onError?.(err);
|
||||
console.error(`Failed to fetch ${resourceName} data:`, err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resourceName, enabled, ...deps]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
export const useDataSourceData = (
|
||||
dataSourceName: string,
|
||||
options: Omit<UseResourceDataOptions, 'resourceName' | 'resourceParams' | 'getParams'> & {
|
||||
getParams?: Record<string, any>;
|
||||
} = {},
|
||||
) => {
|
||||
const { getParams = {}, ...restOptions } = options;
|
||||
|
||||
return useResourceData({
|
||||
resourceName: 'dataSources',
|
||||
resourceParams: dataSourceName,
|
||||
getParams: {
|
||||
filterByTk: dataSourceName,
|
||||
...getParams,
|
||||
},
|
||||
deps: [dataSourceName],
|
||||
...restOptions,
|
||||
});
|
||||
};
|
||||
@@ -150,6 +150,7 @@ export const databaseConnectionSchema: ISchema = {
|
||||
type: 'checkbox',
|
||||
},
|
||||
useDataSource: '{{ cm.useDataSourceFromRAC }}',
|
||||
onSuccess: '{{ dataSourceListCallback }}',
|
||||
},
|
||||
properties: {
|
||||
key: {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Field synchronization confirmation prompt": "Are you sure you want to synchronize fields from database? Depends on the number of tables and fields, This operation may take anywhere from a few seconds to several dozen seconds. If you only need to sync specific tables, please use the \"Configure Fields\" - \"Sync from database\" for those tables.",
|
||||
"Load tables from database": "Load tables from database",
|
||||
"Sync field changes from database": "Sync field changes from database",
|
||||
"Available tables": "Available tables",
|
||||
"Selected tables": "Selected tables"
|
||||
}
|
||||
@@ -30,11 +30,16 @@
|
||||
"Reloading": "加载中",
|
||||
"Data source synchronization in progress": "数据源同步中",
|
||||
"Data source synchronization successful": "数据源同步成功",
|
||||
"Filter target key":"筛选目标键",
|
||||
"Filter target key": "筛选目标键",
|
||||
"Select field": "选择字段",
|
||||
"OK": "确定",
|
||||
"Please select a field.": "请选择一个字段。",
|
||||
"Are you sure you want to set the \"{{title}}\" field as a record unique key? This setting cannot be changed after it's been set.": "你确定将 “{{title}}” 字段设置为主键吗?设置成功后不可修改。",
|
||||
"If a collection lacks a primary key, you must configure a unique record key to locate row records within a block, failure to configure this will prevent the creation of data blocks for the collection.": "当数据表没有主键时,你需要配置记录唯一标识符,用于在区块中定位行记录,不配置将无法创建该表的数据区块。",
|
||||
"Filter data based on the specific field, with the requirement that the field value must be unique.": "根据特定的字段筛选数据,字段值必须具备唯一性。"
|
||||
"Filter data based on the specific field, with the requirement that the field value must be unique.": "根据特定的字段筛选数据,字段值必须具备唯一性。",
|
||||
"Field synchronization confirmation prompt": "确认要对所有数据表执行字段同步吗?取决于数据表和字段数量,可能会耗时几秒到几十秒。若只需同步个别表,请使用相应表的“配置字段” - “从数据库同步”进行操作。",
|
||||
"Load tables from database": "从数据库加载表",
|
||||
"Sync field changes from database": "从数据库同步字段变更",
|
||||
"Available tables": "可选数据表",
|
||||
"Selected tables": "已选数据表"
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { DataSourceModel } from '../models/data-source';
|
||||
import { mapDataSourceWithCollection } from '../utils';
|
||||
import { DataSourceManager, DatabaseDataSource } from '@nocobase/data-source-manager';
|
||||
|
||||
const canRefreshStatus = ['loaded', 'loading-failed', 'reloading-failed'];
|
||||
|
||||
export default {
|
||||
async ['dataSources:listEnabled'](ctx: Context, next: Next) {
|
||||
const dataSources = await ctx.db.getRepository('dataSources').find({
|
||||
filter: {
|
||||
enabled: true,
|
||||
'type.$ne': 'main',
|
||||
},
|
||||
});
|
||||
|
||||
ctx.body = dataSources.map((dataSourceModel: DataSourceModel) => {
|
||||
return mapDataSourceWithCollection(ctx.app, dataSourceModel);
|
||||
});
|
||||
|
||||
await next();
|
||||
},
|
||||
|
||||
async ['dataSources:testConnection'](ctx: Context, next: Next) {
|
||||
const { values } = ctx.action.params;
|
||||
|
||||
const { options, type } = values;
|
||||
|
||||
const klass = ctx.app.dataSourceManager.factory.getClass(type);
|
||||
|
||||
try {
|
||||
await klass.testConnection(ctx.app.environment.renderJsonTemplate(options));
|
||||
} catch (error) {
|
||||
throw new Error(`Test connection failed: ${error.message}`);
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
await next();
|
||||
},
|
||||
|
||||
async ['dataSources:refresh'](ctx: Context, next: Next) {
|
||||
const plugin = ctx.app.pm.get('data-source-manager');
|
||||
const { filterByTk, clientStatus } = ctx.action.params;
|
||||
|
||||
const dataSourceModel: DataSourceModel = await ctx.db.getRepository('dataSources').findOne({
|
||||
filter: {
|
||||
key: filterByTk,
|
||||
},
|
||||
});
|
||||
|
||||
const currentStatus = plugin.dataSourceStatus[filterByTk];
|
||||
|
||||
if (
|
||||
canRefreshStatus.includes(currentStatus) &&
|
||||
(clientStatus ? clientStatus && canRefreshStatus.includes(clientStatus) : true)
|
||||
) {
|
||||
dataSourceModel.loadIntoApplication({
|
||||
app: ctx.app,
|
||||
refresh: true,
|
||||
});
|
||||
|
||||
ctx.app.syncMessageManager.publish(plugin.name, {
|
||||
type: 'loadDataSource',
|
||||
dataSourceKey: dataSourceModel.get('key'),
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
status: plugin.dataSourceStatus[filterByTk],
|
||||
};
|
||||
|
||||
await next();
|
||||
},
|
||||
|
||||
async ['dataSources:readTables'](ctx: Context, next: Next) {
|
||||
const { dataSourceKey, dbOptions } = ctx.action.params.values || {};
|
||||
const dataSourceManager = ctx.app.dataSourceManager as DataSourceManager;
|
||||
let dataSource: DatabaseDataSource;
|
||||
if (dbOptions) {
|
||||
dataSource = dataSourceManager.factory.create(dbOptions.type, {
|
||||
name: dataSourceKey,
|
||||
...dbOptions,
|
||||
}) as DatabaseDataSource;
|
||||
} else {
|
||||
dataSource = dataSourceManager.dataSources.get(dataSourceKey) as DatabaseDataSource;
|
||||
if (!dataSource) {
|
||||
throw new Error(`dataSource ${dataSourceKey} not found`);
|
||||
}
|
||||
}
|
||||
const tables = await dataSource.readTables();
|
||||
ctx.body = tables;
|
||||
await next();
|
||||
},
|
||||
|
||||
async ['dataSources:loadTables'](ctx: Context, next: Next) {
|
||||
const { dataSourceKey, tables } = ctx.action.params.values || {};
|
||||
const dataSourceManager = ctx.app.dataSourceManager as DataSourceManager;
|
||||
const dataSource = dataSourceManager.dataSources.get(dataSourceKey) as DatabaseDataSource;
|
||||
if (!dataSource) {
|
||||
throw new Error(`dataSource ${dataSourceKey} not found`);
|
||||
}
|
||||
await dataSource.loadTables(ctx, tables);
|
||||
await next();
|
||||
},
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { DatabaseDataSource } from '@nocobase/data-source-manager';
|
||||
import { ALLOW_MAX_COLLECTIONS_COUNT } from '../constants';
|
||||
|
||||
export async function loadDataSourceTablesIntoCollections(ctx: Context, next: Next) {
|
||||
const { actionName, resourceName, params } = ctx.action;
|
||||
if (resourceName === 'dataSources' && (actionName === 'create' || actionName === 'update')) {
|
||||
const { options, type, collections, key } = params.values || {};
|
||||
let dataSource: DatabaseDataSource;
|
||||
if (actionName === 'create') {
|
||||
dataSource = ctx.app.dataSourceManager.factory.create(type, {
|
||||
name: key,
|
||||
...options,
|
||||
});
|
||||
dataSource.setLogger(ctx.logger);
|
||||
} else {
|
||||
const { filterByTk: dataSourceKey } = params;
|
||||
dataSource = ctx.app.dataSourceManager.dataSources.get(dataSourceKey);
|
||||
if (!dataSource) {
|
||||
throw new Error(`dataSource ${dataSourceKey} not found`);
|
||||
}
|
||||
}
|
||||
if (!(dataSource instanceof DatabaseDataSource)) {
|
||||
return next();
|
||||
}
|
||||
if (options?.addAllCollections) {
|
||||
const allTables = await dataSource.introspector.getTables();
|
||||
if (allTables.length > ALLOW_MAX_COLLECTIONS_COUNT) {
|
||||
throw new Error(
|
||||
`The number of collections exceeds the limit of ${ALLOW_MAX_COLLECTIONS_COUNT}. Please remove some collections before adding new ones.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await dataSource.loadTables(ctx, collections);
|
||||
}
|
||||
if (collections) {
|
||||
delete ctx.action.params.values.collections;
|
||||
}
|
||||
}
|
||||
await next();
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
*/
|
||||
|
||||
import { Application, Plugin } from '@nocobase/server';
|
||||
import { resolve } from 'path';
|
||||
import { DataSourcesCollectionModel } from './models/data-sources-collection-model';
|
||||
import { DataSourcesFieldModel } from './models/data-sources-field-model';
|
||||
import remoteCollectionsResourcer from './resourcers/data-sources-collections';
|
||||
@@ -16,20 +15,18 @@ import remoteFieldsResourcer from './resourcers/data-sources-collections-fields'
|
||||
import rolesConnectionResourcesResourcer from './resourcers/data-sources-resources';
|
||||
import databaseConnectionsRolesResourcer from './resourcers/data-sources-roles';
|
||||
import { rolesRemoteCollectionsResourcer } from './resourcers/roles-data-sources-collections';
|
||||
|
||||
import { DataSourceManager, LoadingProgress } from '@nocobase/data-source-manager';
|
||||
import dataSourcesActions from './actions/data-sources';
|
||||
import { LoadingProgress } from '@nocobase/data-source-manager';
|
||||
import lodash from 'lodash';
|
||||
import { DataSourcesRolesResourcesModel } from './models/connections-roles-resources';
|
||||
import { DataSourcesRolesResourcesActionModel } from './models/connections-roles-resources-action';
|
||||
import { DataSourceModel } from './models/data-source';
|
||||
import { DataSourcesRolesModel } from './models/data-sources-roles-model';
|
||||
import { mergeRole } from '@nocobase/acl';
|
||||
import { ALLOW_MAX_COLLECTIONS_COUNT } from './constants';
|
||||
import { loadDataSourceTablesIntoCollections } from './middlewares/load-tables';
|
||||
|
||||
type DataSourceState = 'loading' | 'loaded' | 'loading-failed' | 'reloading' | 'reloading-failed';
|
||||
|
||||
const canRefreshStatus = ['loaded', 'loading-failed', 'reloading-failed'];
|
||||
|
||||
export class PluginDataSourceManagerServer extends Plugin {
|
||||
public dataSourceErrors: {
|
||||
[dataSourceKey: string]: Error;
|
||||
@@ -357,43 +354,7 @@ export class PluginDataSourceManagerServer extends Plugin {
|
||||
await next();
|
||||
});
|
||||
|
||||
this.app.resourceManager.use(async function verifyDatasourceCollectionsCount(ctx, next) {
|
||||
if (!ctx.action) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const { actionName, resourceName, params } = ctx.action;
|
||||
if (resourceName === 'dataSources' && (actionName === 'add' || actionName === 'update')) {
|
||||
const { values, filterByTk: dataSourceKey } = params;
|
||||
if (values.options?.addAllCollections) {
|
||||
let introspector: { getCollections: () => Promise<string[]> } = null;
|
||||
const dataSourceManager = ctx.app['dataSourceManager'] as DataSourceManager;
|
||||
|
||||
if (actionName === 'add') {
|
||||
const klass = dataSourceManager.factory.getClass(values.options.type);
|
||||
// @ts-ignore
|
||||
const dataSource = new klass(dbOptions);
|
||||
introspector = dataSource.collectionManager.dataSource.createDatabaseIntrospector(
|
||||
dataSource.collectionManager.db,
|
||||
);
|
||||
} else {
|
||||
const dataSource = dataSourceManager.dataSources.get(dataSourceKey);
|
||||
if (!dataSource) {
|
||||
throw new Error(`dataSource ${dataSourceKey} not found`);
|
||||
}
|
||||
introspector = dataSource['introspector'];
|
||||
}
|
||||
const allCollections = await introspector.getCollections();
|
||||
if (allCollections.length > ALLOW_MAX_COLLECTIONS_COUNT) {
|
||||
throw new Error(
|
||||
`The number of collections exceeds the limit of ${ALLOW_MAX_COLLECTIONS_COUNT}. Please remove some collections before adding new ones.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
this.app.resourceManager.use(loadDataSourceTablesIntoCollections);
|
||||
|
||||
this.app.use(async function handleAppendDataSourceCollection(ctx, next) {
|
||||
await next();
|
||||
@@ -419,83 +380,14 @@ export class PluginDataSourceManagerServer extends Plugin {
|
||||
});
|
||||
|
||||
const self = this;
|
||||
this.app.actions({
|
||||
async ['dataSources:listEnabled'](ctx, next) {
|
||||
const dataSources = await ctx.db.getRepository('dataSources').find({
|
||||
filter: {
|
||||
enabled: true,
|
||||
'type.$ne': 'main',
|
||||
},
|
||||
});
|
||||
this.app.resourceManager.registerActionHandlers(dataSourcesActions);
|
||||
|
||||
ctx.body = dataSources.map((dataSourceModel) => {
|
||||
return mapDataSourceWithCollection(dataSourceModel);
|
||||
});
|
||||
|
||||
await next();
|
||||
},
|
||||
|
||||
async ['dataSources:testConnection'](ctx, next) {
|
||||
const { values } = ctx.action.params;
|
||||
|
||||
const { options, type } = values;
|
||||
|
||||
const klass = ctx.app.dataSourceManager.factory.getClass(type);
|
||||
|
||||
try {
|
||||
await klass.testConnection(self.renderJsonTemplate(options));
|
||||
} catch (error) {
|
||||
throw new Error(`Test connection failed: ${error.message}`);
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
await next();
|
||||
},
|
||||
|
||||
async ['dataSources:refresh'](ctx, next) {
|
||||
const { filterByTk, clientStatus } = ctx.action.params;
|
||||
|
||||
const dataSourceModel: DataSourceModel = await ctx.db.getRepository('dataSources').findOne({
|
||||
filter: {
|
||||
key: filterByTk,
|
||||
},
|
||||
});
|
||||
|
||||
const currentStatus = plugin.dataSourceStatus[filterByTk];
|
||||
|
||||
if (
|
||||
canRefreshStatus.includes(currentStatus) &&
|
||||
(clientStatus ? clientStatus && canRefreshStatus.includes(clientStatus) : true)
|
||||
) {
|
||||
dataSourceModel.loadIntoApplication({
|
||||
app: ctx.app,
|
||||
refresh: true,
|
||||
});
|
||||
|
||||
ctx.app.syncMessageManager.publish(self.name, {
|
||||
type: 'loadDataSource',
|
||||
dataSourceKey: dataSourceModel.get('key'),
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
status: plugin.dataSourceStatus[filterByTk],
|
||||
};
|
||||
|
||||
await next();
|
||||
},
|
||||
});
|
||||
|
||||
this.app.resourcer.define(remoteCollectionsResourcer);
|
||||
this.app.resourcer.define(remoteFieldsResourcer);
|
||||
this.app.resourcer.define(rolesRemoteCollectionsResourcer);
|
||||
this.app.resourcer.define(databaseConnectionsRolesResourcer);
|
||||
this.app.resourcer.define(rolesConnectionResourcesResourcer);
|
||||
|
||||
this.app.resourcer.define({
|
||||
this.app.resourceManager.define(remoteCollectionsResourcer);
|
||||
this.app.resourceManager.define(remoteFieldsResourcer);
|
||||
this.app.resourceManager.define(rolesRemoteCollectionsResourcer);
|
||||
this.app.resourceManager.define(databaseConnectionsRolesResourcer);
|
||||
this.app.resourceManager.define(rolesConnectionResourcesResourcer);
|
||||
this.app.resourceManager.define({
|
||||
name: 'dataSources',
|
||||
});
|
||||
|
||||
@@ -815,9 +707,7 @@ export class PluginDataSourceManagerServer extends Plugin {
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
await this.importCollections(resolve(__dirname, 'collections'));
|
||||
}
|
||||
async load() {}
|
||||
}
|
||||
|
||||
export default PluginDataSourceManagerServer;
|
||||
|
||||
-87
@@ -10,8 +10,6 @@
|
||||
import lodash from 'lodash';
|
||||
import { filterMatch } from '@nocobase/database';
|
||||
import _ from 'lodash';
|
||||
import { DataSourceManager } from '@nocobase/data-source-manager';
|
||||
import { ALLOW_MAX_COLLECTIONS_COUNT } from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'dataSources.collections',
|
||||
@@ -128,90 +126,5 @@ export default {
|
||||
|
||||
await next();
|
||||
},
|
||||
async all(ctx, next) {
|
||||
const params = ctx.action.params;
|
||||
const { associatedIndex: dataSourceKey, isFirst, dbOptions } = params;
|
||||
const dataSourceManager = ctx.app.dataSourceManager as DataSourceManager;
|
||||
let introspector: { getCollections: () => Promise<string[]> } = null;
|
||||
if (isFirst) {
|
||||
const klass = dataSourceManager.factory.getClass(dbOptions.type);
|
||||
// @ts-ignore
|
||||
const dataSource = new klass(dbOptions);
|
||||
introspector = dataSource.collectionManager.dataSource.createDatabaseIntrospector(
|
||||
dataSource.collectionManager.db,
|
||||
);
|
||||
} else {
|
||||
const dataSource = dataSourceManager.dataSources.get(dataSourceKey);
|
||||
if (!dataSource) {
|
||||
throw new Error(`dataSource ${dataSourceKey} not found`);
|
||||
}
|
||||
introspector = dataSource['introspector'];
|
||||
}
|
||||
const allCollections = await introspector.getCollections();
|
||||
const selectedCollections = await ctx.db.getRepository('dataSourcesCollections').find({
|
||||
filter: { dataSourceKey },
|
||||
});
|
||||
const selectedMap = _.keyBy(selectedCollections, (x) => x.name);
|
||||
const result = allCollections.map((collection) => {
|
||||
return {
|
||||
name: collection,
|
||||
selected: !!selectedMap[collection],
|
||||
};
|
||||
});
|
||||
ctx.body = result;
|
||||
await next();
|
||||
},
|
||||
async add(ctx, next) {
|
||||
const params = ctx.action.params;
|
||||
const { associatedIndex: dataSourceKey, values } = params;
|
||||
const collections = values.collections || [];
|
||||
const dbOptions = values.dbOptions || {};
|
||||
if (dbOptions.addAllCollections !== false) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
if (collections.length > ALLOW_MAX_COLLECTIONS_COUNT) {
|
||||
throw new Error(
|
||||
`The number of collections exceeds the limit of ${ALLOW_MAX_COLLECTIONS_COUNT}. Please remove some collections before adding new ones.`,
|
||||
);
|
||||
}
|
||||
const transaction = await ctx.db.sequelize.transaction();
|
||||
const repo = ctx.db.getRepository('dataSourcesCollections');
|
||||
|
||||
const alreadyInserted = await repo.find({
|
||||
filter: {
|
||||
dataSourceKey,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
const alreadyInsertedNames = _.keyBy(alreadyInserted, (x) => x.name);
|
||||
const incomingCollections = _.keyBy(collections);
|
||||
const toBeInserted = collections.filter((collection) => !alreadyInsertedNames[collection]);
|
||||
const toBeDeleted = Object.keys(alreadyInsertedNames).filter((name) => !incomingCollections[name]);
|
||||
if (toBeInserted.length > 0) {
|
||||
const insertCollections = toBeInserted.map((collection) => {
|
||||
return { name: collection, dataSourceKey };
|
||||
});
|
||||
await repo.model.bulkCreate(insertCollections, { transaction });
|
||||
}
|
||||
|
||||
if (toBeDeleted.length > 0) {
|
||||
await repo.model.destroy({
|
||||
where: {
|
||||
dataSourceKey,
|
||||
name: toBeDeleted,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
const dataSource = ctx.app.dataSourceManager.dataSources.get(dataSourceKey);
|
||||
if (dataSource) {
|
||||
await dataSource.load({ refresh: true, condition: toBeInserted ? { name: { $in: toBeInserted } } : {} });
|
||||
}
|
||||
ctx.body = true;
|
||||
await next();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import _ from 'lodash';
|
||||
import { DataSourceModel } from './models/data-source';
|
||||
import { Application } from '@nocobase/server';
|
||||
import PluginDataSourceManagerServer from './plugin';
|
||||
|
||||
export function mergeOptions(fieldOptions, modelOptions) {
|
||||
const newOptions = {
|
||||
@@ -21,3 +24,64 @@ export function mergeOptions(fieldOptions, modelOptions) {
|
||||
}
|
||||
return newOptions;
|
||||
}
|
||||
|
||||
export const mapDataSourceWithCollection = (
|
||||
app: Application,
|
||||
dataSourceModel: DataSourceModel,
|
||||
appendCollections = true,
|
||||
) => {
|
||||
const plugin = app.pm.get('data-source-manager') as PluginDataSourceManagerServer;
|
||||
const dataSource = app.dataSourceManager.dataSources.get(dataSourceModel.get('key'));
|
||||
const dataSourceStatus = plugin.dataSourceStatus[dataSourceModel.get('key')];
|
||||
|
||||
const item: any = {
|
||||
key: dataSourceModel.get('key'),
|
||||
displayName: dataSourceModel.get('displayName'),
|
||||
status: dataSourceStatus,
|
||||
type: dataSourceModel.get('type'),
|
||||
|
||||
// @ts-ignore
|
||||
isDBInstance: !!dataSource?.collectionManager.db,
|
||||
};
|
||||
|
||||
const publicOptions = dataSource?.publicOptions();
|
||||
if (publicOptions) {
|
||||
item['options'] = publicOptions;
|
||||
}
|
||||
|
||||
if (dataSourceStatus === 'loading-failed' || dataSourceStatus === 'reloading-failed') {
|
||||
item['errorMessage'] = plugin.dataSourceErrors[dataSourceModel.get('key')].message;
|
||||
}
|
||||
|
||||
if (!dataSource) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (appendCollections) {
|
||||
const collections = dataSource.collectionManager.getCollections();
|
||||
|
||||
item.collections = collections.map((collection) => {
|
||||
const collectionOptions = collection.options;
|
||||
const collectionInstance = dataSource.collectionManager.getCollection(collectionOptions.name);
|
||||
|
||||
const fields = [...collection.fields.values()].map((field) => field.options);
|
||||
|
||||
const results = {
|
||||
...collectionOptions,
|
||||
fields,
|
||||
};
|
||||
|
||||
if (collectionInstance && collectionInstance.availableActions) {
|
||||
results['availableActions'] = collectionInstance.availableActions();
|
||||
}
|
||||
|
||||
if (collectionInstance && collectionInstance.unavailableActions) {
|
||||
results['unavailableActions'] = collectionInstance.unavailableActions();
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
@@ -6746,7 +6746,7 @@
|
||||
|
||||
"@npmcli/agent@^2.0.0":
|
||||
version "2.2.2"
|
||||
resolved "https://registry.npmmirror.com/@npmcli/agent/-/agent-2.2.2.tgz#967604918e62f620a648c7975461c9c9e74fc5d5"
|
||||
resolved "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz#967604918e62f620a648c7975461c9c9e74fc5d5"
|
||||
integrity sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==
|
||||
dependencies:
|
||||
agent-base "^7.1.0"
|
||||
@@ -6770,7 +6770,7 @@
|
||||
|
||||
"@npmcli/fs@^3.1.0":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.npmmirror.com/@npmcli/fs/-/fs-3.1.1.tgz#59cdaa5adca95d135fc00f2bb53f5771575ce726"
|
||||
resolved "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz#59cdaa5adca95d135fc00f2bb53f5771575ce726"
|
||||
integrity sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==
|
||||
dependencies:
|
||||
semver "^7.3.5"
|
||||
@@ -10880,7 +10880,7 @@ abbrev@1:
|
||||
|
||||
abbrev@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf"
|
||||
resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf"
|
||||
integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==
|
||||
|
||||
abort-controller-x@^0.4.0, abort-controller-x@^0.4.3:
|
||||
@@ -12917,7 +12917,7 @@ cacache@^15.0.5, cacache@^15.2.0:
|
||||
|
||||
cacache@^18.0.0:
|
||||
version "18.0.4"
|
||||
resolved "https://registry.npmmirror.com/cacache/-/cacache-18.0.4.tgz#4601d7578dadb59c66044e157d02a3314682d6a5"
|
||||
resolved "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz#4601d7578dadb59c66044e157d02a3314682d6a5"
|
||||
integrity sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==
|
||||
dependencies:
|
||||
"@npmcli/fs" "^3.1.0"
|
||||
@@ -18664,7 +18664,7 @@ fs-minipass@^2.0.0, fs-minipass@^2.1.0:
|
||||
|
||||
fs-minipass@^3.0.0:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54"
|
||||
resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54"
|
||||
integrity sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==
|
||||
dependencies:
|
||||
minipass "^7.0.3"
|
||||
@@ -20265,7 +20265,7 @@ http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0:
|
||||
|
||||
http-cache-semantics@^4.1.1:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5"
|
||||
resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5"
|
||||
integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==
|
||||
|
||||
http-deceiver@^1.2.7:
|
||||
@@ -21690,11 +21690,6 @@ isexe@^2.0.0:
|
||||
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
isexe@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.npmmirror.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d"
|
||||
integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==
|
||||
|
||||
isobject@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.npmmirror.com/isobject/-/isobject-0.2.0.tgz#a3432192f39b910b5f02cc989487836ec70aa85e"
|
||||
@@ -23443,7 +23438,7 @@ make-error@^1.1.1:
|
||||
|
||||
make-fetch-happen@^13.0.0:
|
||||
version "13.0.1"
|
||||
resolved "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz#273ba2f78f45e1f3a6dca91cede87d9fa4821e36"
|
||||
resolved "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz#273ba2f78f45e1f3a6dca91cede87d9fa4821e36"
|
||||
integrity sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==
|
||||
dependencies:
|
||||
"@npmcli/agent" "^2.0.0"
|
||||
@@ -24956,7 +24951,7 @@ minipass-collect@^1.0.2:
|
||||
|
||||
minipass-collect@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmmirror.com/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863"
|
||||
resolved "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863"
|
||||
integrity sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==
|
||||
dependencies:
|
||||
minipass "^7.0.3"
|
||||
@@ -24974,7 +24969,7 @@ minipass-fetch@^1.3.0, minipass-fetch@^1.3.2:
|
||||
|
||||
minipass-fetch@^3.0.0:
|
||||
version "3.0.5"
|
||||
resolved "https://registry.npmmirror.com/minipass-fetch/-/minipass-fetch-3.0.5.tgz#f0f97e40580affc4a35cc4a1349f05ae36cb1e4c"
|
||||
resolved "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz#f0f97e40580affc4a35cc4a1349f05ae36cb1e4c"
|
||||
integrity sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==
|
||||
dependencies:
|
||||
minipass "^7.0.3"
|
||||
@@ -25553,7 +25548,7 @@ node-fetch@^3.2.0:
|
||||
|
||||
node-gyp@^10.2.0:
|
||||
version "10.3.1"
|
||||
resolved "https://registry.npmmirror.com/node-gyp/-/node-gyp-10.3.1.tgz#1dd1a1a1c6c5c59da1a76aea06a062786b2c8a1a"
|
||||
resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-10.3.1.tgz#1dd1a1a1c6c5c59da1a76aea06a062786b2c8a1a"
|
||||
integrity sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==
|
||||
dependencies:
|
||||
env-paths "^2.2.0"
|
||||
@@ -25710,7 +25705,7 @@ nopt@^5.0.0:
|
||||
|
||||
nopt@^7.0.0:
|
||||
version "7.2.1"
|
||||
resolved "https://registry.npmmirror.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7"
|
||||
resolved "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7"
|
||||
integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==
|
||||
dependencies:
|
||||
abbrev "^2.0.0"
|
||||
@@ -26411,7 +26406,7 @@ ora@^5.4.1:
|
||||
|
||||
oracledb@6.5.1:
|
||||
version "6.5.1"
|
||||
resolved "https://registry.npmmirror.com/oracledb/-/oracledb-6.5.1.tgz#814d985035acdb1a6470b1152af0ca3767569ede"
|
||||
resolved "https://registry.npmjs.org/oracledb/-/oracledb-6.5.1.tgz#814d985035acdb1a6470b1152af0ca3767569ede"
|
||||
integrity sha512-JzoSGei1wnvmqgKnAZK1W650mzHTZXx+7hClV4mwsbY/ZjUtrpnojNJMYJ2jkOhj7XG5oJPfXc4GqDKaNzkxqg==
|
||||
|
||||
ordered-read-streams@^1.0.0:
|
||||
@@ -28354,7 +28349,7 @@ prismjs@~1.27.0:
|
||||
|
||||
proc-log@^4.1.0, proc-log@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.npmmirror.com/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034"
|
||||
resolved "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034"
|
||||
integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==
|
||||
|
||||
process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
|
||||
@@ -31580,7 +31575,7 @@ socks-proxy-agent@^8.0.2:
|
||||
|
||||
socks-proxy-agent@^8.0.3:
|
||||
version "8.0.5"
|
||||
resolved "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee"
|
||||
resolved "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee"
|
||||
integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==
|
||||
dependencies:
|
||||
agent-base "^7.1.2"
|
||||
@@ -31605,7 +31600,7 @@ socks@^2.3.3, socks@^2.6.2, socks@^2.7.1:
|
||||
|
||||
socks@^2.8.3:
|
||||
version "2.8.7"
|
||||
resolved "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea"
|
||||
resolved "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea"
|
||||
integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==
|
||||
dependencies:
|
||||
ip-address "^10.0.1"
|
||||
@@ -31888,7 +31883,7 @@ sshpk@^1.7.0:
|
||||
|
||||
ssri@^10.0.0:
|
||||
version "10.0.6"
|
||||
resolved "https://registry.npmmirror.com/ssri/-/ssri-10.0.6.tgz#a8aade2de60ba2bce8688e3fa349bad05c7dc1e5"
|
||||
resolved "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz#a8aade2de60ba2bce8688e3fa349bad05c7dc1e5"
|
||||
integrity sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==
|
||||
dependencies:
|
||||
minipass "^7.0.3"
|
||||
@@ -34068,7 +34063,7 @@ unique-filename@^1.1.0, unique-filename@^1.1.1:
|
||||
|
||||
unique-filename@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmmirror.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea"
|
||||
resolved "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea"
|
||||
integrity sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==
|
||||
dependencies:
|
||||
unique-slug "^4.0.0"
|
||||
@@ -34082,7 +34077,7 @@ unique-slug@^2.0.0:
|
||||
|
||||
unique-slug@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.npmmirror.com/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3"
|
||||
resolved "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3"
|
||||
integrity sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==
|
||||
dependencies:
|
||||
imurmurhash "^0.1.4"
|
||||
|
||||
Reference in New Issue
Block a user