feat: database view collection (#1587)
* test: create view collection * feat: view collection class * feat: list view * chore: skip sync view collection * test: should create view collection in difference schema * test: create view collection in collection manager * feat: create view collection by user sql * test: view resourcer * feat: view collection * feat: view collection cannot be added, deleted, or modified * feat: view collection cannot be added, deleted, or modified * feat: view collection cannot be added, deleted, or modified * feat: view collection cannot be added, deleted, or modified * refactor: connect to database view * refactor: sync from database * chore: rename list view sql * chore: list view fields api * chore: create collection without viewName * feat: bring out fields when selecting a view * chore: bring out fields when selecting a view * feat: view field inference class * chore: bring out fields when selecting a view * chore: sync form database view * chore: sync form database view * refactor: view collection local * feat: view get api * feat: database type infer * feat: integer map * chore: remove from in view list * chore: build error * chore: uniq collection * fix: typo * chore: replace collection list source field * fix: destroy view collection * chore: timestamp field map * refactor: interface avalableTypes * refactor: interface avalableTypes * chore: list fields test * refactor: interface avalableTypes * chore: uiSchema response in field source * fix: view query * chore: collection snippet * refactor: view collection support preview * fix: handle field source * fix: typo * fix: configure fileds title * fix: configure fileds title * fix: configure fileds title * fix: sync from databse interface * fix: sync from databse interface * feat: set fields api * fix: sync from databse fix * feat: possibleTypes * chore: fields get * fix: sync from databse * fix: list view test * fix: view test in difference schema * chore: comment * feat: when there is only one source collection, the view is a subset of a Collection * feat: view collection add field * fix: inherit query with schema * fix: test * fix: ci test * fix: test with schema * chore: set pg default search path * chore: mysql test * fix: test with schema * chore: test * chore: action test * chore: view column usage return type * feat: mysql field inference * fix: tableName * chore: node sql parser * fix: sql build * fix: database build * fix: mysql test * feat: view collection uiSchema title * fix: incorrect field source display when switching views * refactor: view collection not allow modify * fix: view collection is allow add, delete, and modify * fix: mysql test * fix: sqlite test * fix: sqlite test * fix: sqlite test * fix: sqlite test * chore: add id field as default target key * style: style improve * feat: load source field options * style: style improve * chore: disable remove column in view collection * chore: support creating view collection with different schemas with the same name * chore: support creating view collection with different schemas with the same name * fix: query view in difference schema * refactor: view collection viewname * fix: query view collection in difference schema * fix: field load * chore: field options * fix: mysql test * fix: uiSchema component error when using a view field in a block * fix: sqlite test * chore: test * fix: dump user views * fix: view collection can be updated and edited in table block * chore: sync from database display last field configuration * chore: loadCollections * chore: sync from database display last field configuration * fix: field options merge issues * style: preview table * fix: view collection is allow using in kanban blocks * refactor: code improve * fix: view collection can be updated an edited in calendar block * chore: disable infer field without interface * feat: preview only shows source or interface fields * fix: test * refactor: locale * feat: sql parser * chore: remove node-sql-parser * fix: yarn.lock * test: view repository * fix: view repository test * chore: console.log * chore: console.log * fix: mysql without schema * fix: mysql without schema * chore: preview with field schema * chore: tableActionInitializers * style: preview style improve * chore: parameter is filter when there is no filterByTk * fix: preview pagination * fix: preview pagination * style: preview table style improve * fix: sync from database loading * chore: preview performance optimization * chore: preview performance optimization * feat: limit & offset * chore: preview performance optimization * test: field with dot column * fix: datetime interface display * fix: missing boolean type * fix: sync * fix: sync from database * style: style improve * style: style improve * style: style improve * chore: preview table * chore: preview table * chore: preview table * fix: styling --------- Co-authored-by: katherinehhh <katherine_15995@163.com> Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
bcc417a79f
commit
4f87de7da5
@ -25,13 +25,15 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- nocobase
|
- nocobase
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:10
|
image: postgres:latest
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- nocobase
|
- nocobase
|
||||||
command: postgres -c wal_level=logical
|
command: postgres -c wal_level=logical
|
||||||
ports:
|
ports:
|
||||||
- "${DB_POSTGRES_PORT}:5432"
|
- "${DB_POSTGRES_PORT}:5432"
|
||||||
|
volumes:
|
||||||
|
- ./storage/db/postgres/backups:/backups
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${DB_USER}
|
POSTGRES_USER: ${DB_USER}
|
||||||
POSTGRES_DB: ${DB_DATABASE}
|
POSTGRES_DB: ${DB_DATABASE}
|
||||||
|
@ -9,6 +9,7 @@ describe('create action', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
app = mockServer();
|
app = mockServer();
|
||||||
|
await app.db.clean({ drop: true });
|
||||||
registerActions(app);
|
registerActions(app);
|
||||||
|
|
||||||
Post = app.collection({
|
Post = app.collection({
|
||||||
|
@ -13,6 +13,8 @@ describe('remove action', () => {
|
|||||||
app = mockServer();
|
app = mockServer();
|
||||||
registerActions(app);
|
registerActions(app);
|
||||||
|
|
||||||
|
await app.db.clean({ drop: true });
|
||||||
|
|
||||||
PostTag = app.collection({
|
PostTag = app.collection({
|
||||||
name: 'posts_tags',
|
name: 'posts_tags',
|
||||||
fields: [{ type: 'string', name: 'tagged_at' }],
|
fields: [{ type: 'string', name: 'tagged_at' }],
|
||||||
|
@ -10,6 +10,7 @@ describe('set action', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
app = mockServer();
|
app = mockServer();
|
||||||
|
await app.db.clean({ drop: true });
|
||||||
registerActions(app);
|
registerActions(app);
|
||||||
|
|
||||||
PostTag = app.collection({
|
PostTag = app.collection({
|
||||||
|
@ -10,6 +10,7 @@ describe('toggle action', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
app = mockServer();
|
app = mockServer();
|
||||||
|
await app.db.clean({ drop: true });
|
||||||
registerActions(app);
|
registerActions(app);
|
||||||
|
|
||||||
PostTag = app.collection({
|
PostTag = app.collection({
|
||||||
|
@ -292,9 +292,27 @@ export const useSourceIdFromParentRecord = () => {
|
|||||||
|
|
||||||
export const useParamsFromRecord = () => {
|
export const useParamsFromRecord = () => {
|
||||||
const filterByTk = useFilterByTk();
|
const filterByTk = useFilterByTk();
|
||||||
return {
|
const record = useRecord();
|
||||||
|
const { fields } = useCollection();
|
||||||
|
const filterFields = fields
|
||||||
|
.filter((v) => {
|
||||||
|
return ['boolean', 'date', 'integer', 'radio', 'sort', 'string', 'time', 'uid', 'uuid'].includes(v.type);
|
||||||
|
})
|
||||||
|
.map((v) => v.name);
|
||||||
|
const filter = Object.keys(record)
|
||||||
|
.filter((key) => filterFields.includes(key))
|
||||||
|
.reduce((result, key) => {
|
||||||
|
result[key] = record[key];
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const obj = {
|
||||||
filterByTk: filterByTk,
|
filterByTk: filterByTk,
|
||||||
};
|
};
|
||||||
|
if (!filterByTk) {
|
||||||
|
obj['filter'] = filter;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RecordLink = (props) => {
|
export const RecordLink = (props) => {
|
||||||
|
@ -13,7 +13,7 @@ const InternalField: React.FC = (props) => {
|
|||||||
const fieldSchema = useFieldSchema();
|
const fieldSchema = useFieldSchema();
|
||||||
const { name, interface: interfaceType, uiSchema, defaultValue } = useCollectionField();
|
const { name, interface: interfaceType, uiSchema, defaultValue } = useCollectionField();
|
||||||
const collectionField = useCollectionField();
|
const collectionField = useCollectionField();
|
||||||
const component = useComponent(uiSchema?.['x-component']);
|
const component = useComponent(uiSchema?.['x-component'] || 'Input');
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
const setFieldProps = (key, value) => {
|
const setFieldProps = (key, value) => {
|
||||||
field[key] = typeof field[key] === 'undefined' ? value : field[key];
|
field[key] = typeof field[key] === 'undefined' ? value : field[key];
|
||||||
@ -73,7 +73,6 @@ const InternalField: React.FC = (props) => {
|
|||||||
if (!uiSchema) {
|
if (!uiSchema) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return React.createElement(component, props, props.children);
|
return React.createElement(component, props, props.children);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,7 +106,6 @@ export const CollectionField = connect((props) => {
|
|||||||
const fieldSchema = useFieldSchema();
|
const fieldSchema = useFieldSchema();
|
||||||
const field = fieldSchema?.['x-component-props']?.['field'];
|
const field = fieldSchema?.['x-component-props']?.['field'];
|
||||||
const { snapshot } = useActionContext();
|
const { snapshot } = useActionContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollectionFieldProvider
|
<CollectionFieldProvider
|
||||||
name={fieldSchema.name}
|
name={fieldSchema.name}
|
||||||
|
@ -26,6 +26,8 @@ import {
|
|||||||
ConfigurationTabs,
|
ConfigurationTabs,
|
||||||
EditCategory,
|
EditCategory,
|
||||||
EditCategoryAction,
|
EditCategoryAction,
|
||||||
|
SyncFieldsAction,
|
||||||
|
SyncFieldsActionCom
|
||||||
} from './Configuration';
|
} from './Configuration';
|
||||||
|
|
||||||
import { CollectionCategroriesProvider } from './CollectionManagerProvider';
|
import { CollectionCategroriesProvider } from './CollectionManagerProvider';
|
||||||
@ -81,6 +83,8 @@ export const CollectionManagerPane = () => {
|
|||||||
ViewFieldAction,
|
ViewFieldAction,
|
||||||
EditCategory,
|
EditCategory,
|
||||||
EditCategoryAction,
|
EditCategoryAction,
|
||||||
|
SyncFieldsAction,
|
||||||
|
SyncFieldsActionCom
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
// </Card>
|
// </Card>
|
||||||
|
@ -28,8 +28,9 @@ const getSchema = (schema, category, compile): ISchema => {
|
|||||||
properties['defaultValue']['x-decorator'] = 'FormItem';
|
properties['defaultValue']['x-decorator'] = 'FormItem';
|
||||||
}
|
}
|
||||||
const initialValue: any = {
|
const initialValue: any = {
|
||||||
name: `t_${uid()}`,
|
name: schema.name !== 'view' ? `t_${uid()}` : null,
|
||||||
template: schema.name,
|
template: schema.name,
|
||||||
|
view: schema.name === 'view',
|
||||||
category,
|
category,
|
||||||
...cloneDeep(schema.default),
|
...cloneDeep(schema.default),
|
||||||
};
|
};
|
||||||
@ -201,7 +202,7 @@ const useCreateCollection = (schema?: any) => {
|
|||||||
if (schema?.events?.beforeSubmit) {
|
if (schema?.events?.beforeSubmit) {
|
||||||
schema.events.beforeSubmit(values);
|
schema.events.beforeSubmit(values);
|
||||||
}
|
}
|
||||||
const fields = useDefaultCollectionFields(values);
|
const fields = values?.template !== 'view' ? useDefaultCollectionFields(values) : values.fields;
|
||||||
if (values.autoCreateReverseField) {
|
if (values.autoCreateReverseField) {
|
||||||
} else {
|
} else {
|
||||||
delete values.reverseField;
|
delete values.reverseField;
|
||||||
@ -235,8 +236,15 @@ export const AddCollectionAction = (props) => {
|
|||||||
const [schema, setSchema] = useState({});
|
const [schema, setSchema] = useState({});
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const items = templateOptions().map((option) => {
|
const collectionTemplates = templateOptions();
|
||||||
return { label: compile(option.title), key: option.name };
|
const items = [];
|
||||||
|
collectionTemplates.forEach((item) => {
|
||||||
|
if (item.divider) {
|
||||||
|
items.push({
|
||||||
|
type: 'divider',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({ label: compile(item.title), key: item.name });
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
state: { category },
|
state: { category },
|
||||||
|
@ -212,72 +212,74 @@ export const AddFieldAction = (props) => {
|
|||||||
return optionArr;
|
return optionArr;
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<RecordProvider record={record}>
|
record.template !== 'view' && (
|
||||||
<ActionContext.Provider value={{ visible, setVisible }}>
|
<RecordProvider record={record}>
|
||||||
<Dropdown
|
<ActionContext.Provider value={{ visible, setVisible }}>
|
||||||
getPopupContainer={getContainer}
|
<Dropdown
|
||||||
trigger={trigger}
|
getPopupContainer={getContainer}
|
||||||
align={align}
|
trigger={trigger}
|
||||||
overlay={
|
align={align}
|
||||||
<Menu
|
overlay={
|
||||||
style={{
|
<Menu
|
||||||
maxHeight: '60vh',
|
style={{
|
||||||
overflow: 'auto',
|
maxHeight: '60vh',
|
||||||
}}
|
overflow: 'auto',
|
||||||
onClick={(e) => {
|
}}
|
||||||
//@ts-ignore
|
onClick={(e) => {
|
||||||
const targetScope = e.item.props['data-targetScope'];
|
//@ts-ignore
|
||||||
targetScope && setTargetScope(targetScope);
|
const targetScope = e.item.props['data-targetScope'];
|
||||||
const schema = getSchema(getInterface(e.key), record, compile);
|
targetScope && setTargetScope(targetScope);
|
||||||
if (schema) {
|
const schema = getSchema(getInterface(e.key), record, compile);
|
||||||
setSchema(schema);
|
if (schema) {
|
||||||
setVisible(true);
|
setSchema(schema);
|
||||||
}
|
setVisible(true);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
{getFieldOptions().map((option) => {
|
>
|
||||||
return (
|
{getFieldOptions().map((option) => {
|
||||||
option.children.length > 0 && (
|
return (
|
||||||
<Menu.ItemGroup key={option.label} title={compile(option.label)}>
|
option.children.length > 0 && (
|
||||||
{option.children
|
<Menu.ItemGroup key={option.label} title={compile(option.label)}>
|
||||||
.filter((child) => !['o2o', 'subTable'].includes(child.name))
|
{option.children
|
||||||
.map((child) => {
|
.filter((child) => !['o2o', 'subTable'].includes(child.name))
|
||||||
return (
|
.map((child) => {
|
||||||
<Menu.Item key={child.name} data-targetScope={child.targetScope}>
|
return (
|
||||||
{compile(child.title)}
|
<Menu.Item key={child.name} data-targetScope={child.targetScope}>
|
||||||
</Menu.Item>
|
{compile(child.title)}
|
||||||
);
|
</Menu.Item>
|
||||||
})}
|
);
|
||||||
</Menu.ItemGroup>
|
})}
|
||||||
)
|
</Menu.ItemGroup>
|
||||||
);
|
)
|
||||||
})}
|
);
|
||||||
</Menu>
|
})}
|
||||||
}
|
</Menu>
|
||||||
>
|
}
|
||||||
{children || (
|
>
|
||||||
<Button icon={<PlusOutlined />} type={'primary'}>
|
{children || (
|
||||||
{t('Add field')}
|
<Button icon={<PlusOutlined />} type={'primary'}>
|
||||||
</Button>
|
{t('Add field')}
|
||||||
)}
|
</Button>
|
||||||
</Dropdown>
|
)}
|
||||||
<SchemaComponent
|
</Dropdown>
|
||||||
schema={schema}
|
<SchemaComponent
|
||||||
components={{ ...components, ArrayTable }}
|
schema={schema}
|
||||||
scope={{
|
components={{ ...components, ArrayTable }}
|
||||||
getContainer,
|
scope={{
|
||||||
useCancelAction,
|
getContainer,
|
||||||
createOnly: true,
|
useCancelAction,
|
||||||
isOverride: false,
|
createOnly: true,
|
||||||
override: false,
|
isOverride: false,
|
||||||
useCreateCollectionField,
|
override: false,
|
||||||
record,
|
useCreateCollectionField,
|
||||||
showReverseFieldConfig: true,
|
record,
|
||||||
targetScope,
|
showReverseFieldConfig: true,
|
||||||
...scope,
|
targetScope,
|
||||||
}}
|
...scope,
|
||||||
/>
|
}}
|
||||||
</ActionContext.Provider>
|
/>
|
||||||
</RecordProvider>
|
</ActionContext.Provider>
|
||||||
|
</RecordProvider>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -14,6 +14,7 @@ import { AddSubFieldAction } from './AddSubFieldAction';
|
|||||||
import { FieldSummary } from './components/FieldSummary';
|
import { FieldSummary } from './components/FieldSummary';
|
||||||
import { EditSubFieldAction } from './EditSubFieldAction';
|
import { EditSubFieldAction } from './EditSubFieldAction';
|
||||||
import { collectionSchema } from './schemas/collections';
|
import { collectionSchema } from './schemas/collections';
|
||||||
|
import { useAPIClient } from '../../api-client';
|
||||||
|
|
||||||
const useAsyncDataSource = (service: any) => {
|
const useAsyncDataSource = (service: any) => {
|
||||||
return (field: any, options?: any) => {
|
return (field: any, options?: any) => {
|
||||||
@ -77,23 +78,32 @@ export const ConfigurationTable = () => {
|
|||||||
const {
|
const {
|
||||||
data: { database },
|
data: { database },
|
||||||
} = useCurrentAppInfo();
|
} = useCurrentAppInfo();
|
||||||
|
|
||||||
const data = useContext(CollectionCategroriesContext);
|
const data = useContext(CollectionCategroriesContext);
|
||||||
|
const api = useAPIClient();
|
||||||
|
const resource = api.resource('dbViews');
|
||||||
const collectonsRef: any = useRef();
|
const collectonsRef: any = useRef();
|
||||||
collectonsRef.current = collections;
|
collectonsRef.current = collections;
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
const loadCollections = async (field, options) => {
|
const loadCollections = async (field, options) => {
|
||||||
const { targetScope } = options;
|
const { targetScope } = options;
|
||||||
return collectonsRef.current
|
const isFieldInherits = field.props?.name === 'inherits';
|
||||||
?.filter((item) => !(item.autoCreate && item.isThrough))
|
const filteredItems = collectonsRef.current.filter((item) => {
|
||||||
.filter((item) =>
|
const isAutoCreateAndThrough = item.autoCreate && item.isThrough;
|
||||||
targetScope
|
if (isAutoCreateAndThrough) {
|
||||||
? targetScope['template']?.includes(item.template) || targetScope[field.props.name]?.includes(item.name)
|
return false;
|
||||||
: true,
|
}
|
||||||
)
|
if (isFieldInherits && item.template === 'view') {
|
||||||
.map((item: any) => ({
|
return false;
|
||||||
label: compile(item.title),
|
}
|
||||||
value: item.name,
|
const templateIncluded = !targetScope?.template || targetScope.template.includes(item.template);
|
||||||
}));
|
const nameIncluded = !targetScope?.[field.props?.name] || targetScope[field.props.name].includes(item.name);
|
||||||
|
return templateIncluded && nameIncluded;
|
||||||
|
});
|
||||||
|
return filteredItems.map((item) => ({
|
||||||
|
label: compile(item.title),
|
||||||
|
value: item.name,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
return data.data.map((item: any) => ({
|
return data.data.map((item: any) => ({
|
||||||
@ -101,31 +111,45 @@ export const ConfigurationTable = () => {
|
|||||||
value: item.id,
|
value: item.id,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDBViews = async () => {
|
||||||
|
return resource.list().then(({ data }) => {
|
||||||
|
return data?.data?.map((item: any) => {
|
||||||
|
const schema = item.schema;
|
||||||
|
return {
|
||||||
|
label: schema ? `${schema}.${compile(item.name)}` : item.name,
|
||||||
|
value: schema?`${schema}_${item.name}`:item.name
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
const ctx = useContext(SchemaComponentContext);
|
const ctx = useContext(SchemaComponentContext);
|
||||||
return (
|
return (
|
||||||
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
|
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
|
||||||
<SchemaComponent
|
<SchemaComponent
|
||||||
schema={collectionSchema}
|
schema={collectionSchema}
|
||||||
components={{
|
components={{
|
||||||
AddSubFieldAction,
|
AddSubFieldAction,
|
||||||
EditSubFieldAction,
|
EditSubFieldAction,
|
||||||
FieldSummary,
|
FieldSummary,
|
||||||
CollectionFieldsTable,
|
CollectionFieldsTable,
|
||||||
}}
|
}}
|
||||||
scope={{
|
scope={{
|
||||||
useDestroySubField,
|
useDestroySubField,
|
||||||
useBulkDestroySubField,
|
useBulkDestroySubField,
|
||||||
useSelectedRowKeys,
|
useSelectedRowKeys,
|
||||||
useAsyncDataSource,
|
useAsyncDataSource,
|
||||||
loadCollections,
|
loadCollections,
|
||||||
loadCategories,
|
loadCategories,
|
||||||
useCurrentFields,
|
loadDBViews,
|
||||||
useNewId,
|
useCurrentFields,
|
||||||
useCancelAction,
|
useNewId,
|
||||||
interfaces,
|
useCancelAction,
|
||||||
enableInherits: database?.dialect === 'postgres',
|
interfaces,
|
||||||
}}
|
enableInherits: database?.dialect === 'postgres',
|
||||||
/>
|
isPG:database?.dialect === 'postgres',
|
||||||
</SchemaComponentContext.Provider>
|
}}
|
||||||
|
/>
|
||||||
|
</SchemaComponentContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -19,10 +19,12 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const properties = cloneDeep(schema.properties) as any;
|
const properties = cloneDeep(schema.properties) as any;
|
||||||
properties.name['x-disabled'] = true;
|
if (properties?.name) {
|
||||||
|
properties.name['x-disabled'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (schema.hasDefaultValue === true) {
|
if (schema.hasDefaultValue === true) {
|
||||||
properties['defaultValue'] = cloneDeep(schema.default.uiSchema);
|
properties['defaultValue'] = cloneDeep(schema.default.uiSchema)||{};
|
||||||
properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
|
properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
|
||||||
properties['defaultValue']['x-decorator'] = 'FormItem';
|
properties['defaultValue']['x-decorator'] = 'FormItem';
|
||||||
properties['defaultValue']['x-reactions'] = {
|
properties['defaultValue']['x-reactions'] = {
|
||||||
@ -156,7 +158,7 @@ export const EditFieldAction = (props) => {
|
|||||||
const defaultValues: any = cloneDeep(data?.data) || {};
|
const defaultValues: any = cloneDeep(data?.data) || {};
|
||||||
if (!defaultValues?.reverseField) {
|
if (!defaultValues?.reverseField) {
|
||||||
defaultValues.autoCreateReverseField = false;
|
defaultValues.autoCreateReverseField = false;
|
||||||
defaultValues.reverseField = interfaceConf.default?.reverseField;
|
defaultValues.reverseField = interfaceConf?.default?.reverseField;
|
||||||
set(defaultValues.reverseField, 'name', `f_${uid()}`);
|
set(defaultValues.reverseField, 'name', `f_${uid()}`);
|
||||||
set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title);
|
set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,194 @@
|
|||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { ArrayTable } from '@formily/antd';
|
||||||
|
import { useForm } from '@formily/react';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRequest } from '../../api-client';
|
||||||
|
import { RecordProvider, useRecord } from '../../record-provider';
|
||||||
|
import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
|
||||||
|
import { useCancelAction } from '../action-hooks';
|
||||||
|
import { useCollectionManager } from '../hooks';
|
||||||
|
import { IField } from '../interfaces/types';
|
||||||
|
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
|
||||||
|
import * as components from './components';
|
||||||
|
import { useAPIClient } from '../../api-client';
|
||||||
|
import { PreviewFields } from '../templates/components/PreviewFields';
|
||||||
|
import { PreviewTable } from '../templates/components/PreviewTable';
|
||||||
|
|
||||||
|
const getSchema = (schema: IField, record: any, compile) => {
|
||||||
|
if (!schema) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = cloneDeep(schema.properties) as any;
|
||||||
|
|
||||||
|
if (schema.hasDefaultValue === true) {
|
||||||
|
properties['defaultValue'] = cloneDeep(schema?.default?.uiSchema);
|
||||||
|
properties['defaultValue']['title'] = compile('{{ t("Default value") }}');
|
||||||
|
properties['defaultValue']['x-decorator'] = 'FormItem';
|
||||||
|
}
|
||||||
|
const initialValue: any = {
|
||||||
|
name: `f_${uid()}`,
|
||||||
|
...cloneDeep(schema.default),
|
||||||
|
interface: schema.name,
|
||||||
|
};
|
||||||
|
if (initialValue.reverseField) {
|
||||||
|
initialValue.reverseField.name = `f_${uid()}`;
|
||||||
|
}
|
||||||
|
// initialValue.uiSchema.title = schema.title;
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
[uid()]: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Action.Drawer',
|
||||||
|
'x-component-props': {
|
||||||
|
getContainer: '{{ getContainer }}',
|
||||||
|
},
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-decorator-props': {
|
||||||
|
useValues(options) {
|
||||||
|
return useRequest(
|
||||||
|
() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: initialValue,
|
||||||
|
}),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: `${compile('{{ t("Sync from database") }}')}`,
|
||||||
|
properties: {
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
'x-hidden': true,
|
||||||
|
default: record?.schema,
|
||||||
|
},
|
||||||
|
viewName: {
|
||||||
|
type: 'string',
|
||||||
|
'x-hidden': true,
|
||||||
|
default: record?.viewName,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
type: 'array',
|
||||||
|
'x-component': PreviewFields,
|
||||||
|
'x-component-props': {
|
||||||
|
...record,
|
||||||
|
},
|
||||||
|
default: record.fields,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
type: 'object',
|
||||||
|
'x-component': PreviewTable,
|
||||||
|
'x-component-props': {
|
||||||
|
...record,
|
||||||
|
},
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['fields'],
|
||||||
|
fulfill: {
|
||||||
|
schema: {
|
||||||
|
'x-component-props': '{{{...record,...$form.values}}}', //任意层次属性都支持表达式
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Action.Drawer.Footer',
|
||||||
|
properties: {
|
||||||
|
action1: {
|
||||||
|
title: '{{ t("Cancel") }}',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
useAction: '{{ useCancelAction }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action2: {
|
||||||
|
title: '{{ t("Submit") }}',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
useAction: '{{ useSyncFromDatabase }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSyncFromDatabase = () => {
|
||||||
|
const form = useForm();
|
||||||
|
const { refreshCM } = useCollectionManager();
|
||||||
|
const ctx = useActionContext();
|
||||||
|
const { refresh } = useResourceActionContext();
|
||||||
|
const { targetKey } = useResourceContext();
|
||||||
|
const { [targetKey]: filterByTk } = useRecord();
|
||||||
|
const api = useAPIClient();
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
await form.submit();
|
||||||
|
await api.resource(`collections`).setFields({
|
||||||
|
filterByTk,
|
||||||
|
values: form.values,
|
||||||
|
});
|
||||||
|
ctx.setVisible(false);
|
||||||
|
await form.reset();
|
||||||
|
refresh();
|
||||||
|
await refreshCM();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SyncFieldsAction = (props) => {
|
||||||
|
const record = useRecord();
|
||||||
|
return <SyncFieldsActionCom item={record} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SyncFieldsActionCom = (props) => {
|
||||||
|
const { scope, getContainer, item: record, children } = props;
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [schema, setSchema] = useState({});
|
||||||
|
const compile = useCompile();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
record.template === 'view' && (
|
||||||
|
<RecordProvider record={record}>
|
||||||
|
<ActionContext.Provider value={{ visible, setVisible }}>
|
||||||
|
{children || (
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
const schema = getSchema({}, record, compile);
|
||||||
|
if (schema) {
|
||||||
|
setSchema(schema);
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Sync from database')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<SchemaComponent
|
||||||
|
schema={schema}
|
||||||
|
components={{ ...components, ArrayTable }}
|
||||||
|
scope={{
|
||||||
|
getContainer,
|
||||||
|
useCancelAction,
|
||||||
|
createOnly: true,
|
||||||
|
isOverride: false,
|
||||||
|
useSyncFromDatabase,
|
||||||
|
record,
|
||||||
|
...scope,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ActionContext.Provider>
|
||||||
|
</RecordProvider>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
@ -13,7 +13,8 @@ export * from './AddCollectionAction';
|
|||||||
export * from './EditCollectionAction';
|
export * from './EditCollectionAction';
|
||||||
export * from './ConfigurationTabs';
|
export * from './ConfigurationTabs';
|
||||||
export * from './AddCategoryAction';
|
export * from './AddCategoryAction';
|
||||||
export * from './EditCategoryAction'
|
export * from './EditCategoryAction';
|
||||||
|
export * from './SyncFieldsAction';
|
||||||
|
|
||||||
registerValidateFormats({
|
registerValidateFormats({
|
||||||
uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/,
|
uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/,
|
||||||
|
@ -73,7 +73,7 @@ export const collectionFieldSchema: ISchema = {
|
|||||||
params: {
|
params: {
|
||||||
paginate: false,
|
paginate: false,
|
||||||
filter: {
|
filter: {
|
||||||
'interface.$not': null,
|
$or: [{ 'interface.$not': null }, { 'options.source.$notEmpty': true }],
|
||||||
},
|
},
|
||||||
sort: ['sort'],
|
sort: ['sort'],
|
||||||
// appends: ['uiSchema'],
|
// appends: ['uiSchema'],
|
||||||
@ -106,6 +106,14 @@ export const collectionFieldSchema: ISchema = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
syncfromDatabase: {
|
||||||
|
type: 'void',
|
||||||
|
title: '{{ t("Sync from database") }}',
|
||||||
|
'x-component': 'SyncFieldsAction',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
title: '{{ t("Add new") }}',
|
title: '{{ t("Add new") }}',
|
||||||
|
@ -258,7 +258,8 @@ export const collectionTableSchema: ISchema = {
|
|||||||
},
|
},
|
||||||
'x-reactions': (field) => {
|
'x-reactions': (field) => {
|
||||||
const i = field.path.segments[1];
|
const i = field.path.segments[1];
|
||||||
const table = field.form.getValuesIn(`table.${i}`);
|
const key = field.path.segments[0];
|
||||||
|
const table = field.form.getValuesIn(`${key}.${i}`);
|
||||||
if (table) {
|
if (table) {
|
||||||
field.title = `${compile(table.title)} - ${compile('{{ t("Configure fields") }}')}`;
|
field.title = `${compile(table.title)} - ${compile('{{ t("Configure fields") }}')}`;
|
||||||
}
|
}
|
||||||
|
@ -62,7 +62,7 @@ export const useCollectionManager = () => {
|
|||||||
return getParents(name);
|
return getParents(name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChildrenCollections = (name) => {
|
const getChildrenCollections = (name, isSupportView = false) => {
|
||||||
const children = [];
|
const children = [];
|
||||||
const getChildren = (name) => {
|
const getChildren = (name) => {
|
||||||
const inheritCollections = collections.filter((v) => {
|
const inheritCollections = collections.filter((v) => {
|
||||||
@ -73,6 +73,16 @@ export const useCollectionManager = () => {
|
|||||||
children.push(v);
|
children.push(v);
|
||||||
return getChildren(collectionKey);
|
return getChildren(collectionKey);
|
||||||
});
|
});
|
||||||
|
if (isSupportView) {
|
||||||
|
const sourceCollections = collections.filter((v) => {
|
||||||
|
return v.sources?.length === 1 && v?.sources[0] === name;
|
||||||
|
});
|
||||||
|
sourceCollections.forEach((v) => {
|
||||||
|
const collectionKey = v.name;
|
||||||
|
children.push(v);
|
||||||
|
return getChildren(collectionKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
return uniqBy(children, 'key');
|
return uniqBy(children, 'key');
|
||||||
};
|
};
|
||||||
return getChildren(name);
|
return getChildren(name);
|
||||||
|
@ -22,6 +22,7 @@ export const attachment: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['belongsToMany'],
|
||||||
schemaInitialize(schema: ISchema, { block }) {
|
schemaInitialize(schema: ISchema, { block }) {
|
||||||
if (['Table', 'Kanban'].includes(block)) {
|
if (['Table', 'Kanban'].includes(block)) {
|
||||||
schema['x-component-props'] = schema['x-component-props'] || {};
|
schema['x-component-props'] = schema['x-component-props'] || {};
|
||||||
|
@ -15,6 +15,7 @@ export const checkbox: IField = {
|
|||||||
'x-component': 'Checkbox',
|
'x-component': 'Checkbox',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['boolean'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -17,6 +17,7 @@ export const checkboxGroup: IField = {
|
|||||||
'x-component': 'Checkbox.Group',
|
'x-component': 'Checkbox.Group',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['array'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -34,6 +34,7 @@ export const chinaRegion: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['belongsToMany'],
|
||||||
initialize: (values: any) => {
|
initialize: (values: any) => {
|
||||||
if (!values.through) {
|
if (!values.through) {
|
||||||
values.through = `t_${uid()}`;
|
values.through = `t_${uid()}`;
|
||||||
|
@ -20,6 +20,7 @@ export const createdAt: IField = {
|
|||||||
'x-read-pretty': true,
|
'x-read-pretty': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['date'],
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
...dateTimeProps,
|
...dateTimeProps,
|
||||||
|
@ -28,6 +28,7 @@ export const createdBy: IField = {
|
|||||||
'x-read-pretty': true,
|
'x-read-pretty': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['belongsTo'],
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
},
|
},
|
||||||
|
@ -20,6 +20,7 @@ export const datetime: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['date'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -18,6 +18,7 @@ export const email: IField = {
|
|||||||
'x-validator': 'email',
|
'x-validator': 'email',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['string'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -16,6 +16,7 @@ export const icon: IField = {
|
|||||||
'x-component': 'IconPicker',
|
'x-component': 'IconPicker',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['string'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -21,6 +21,7 @@ export const id: IField = {
|
|||||||
'x-read-pretty': true,
|
'x-read-pretty': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['bigInt','integer'],
|
||||||
properties: {
|
properties: {
|
||||||
'uiSchema.title': {
|
'uiSchema.title': {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
@ -18,6 +18,7 @@ export const input: IField = {
|
|||||||
'x-component': 'Input',
|
'x-component': 'Input',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['string'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -29,6 +29,7 @@ export const integer: IField = {
|
|||||||
'x-validator': 'integer',
|
'x-validator': 'integer',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['bigInt','integer'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -39,6 +39,7 @@ export const json: IField = {
|
|||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['json','array'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -45,6 +45,7 @@ export const linkTo: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['belongsToMany'],
|
||||||
schemaInitialize(schema: ISchema, { readPretty, block }) {
|
schemaInitialize(schema: ISchema, { readPretty, block }) {
|
||||||
if (block === 'Form') {
|
if (block === 'Form') {
|
||||||
if (schema['x-component'] === 'AssociationSelect') {
|
if (schema['x-component'] === 'AssociationSelect') {
|
||||||
|
@ -51,6 +51,7 @@ export const m2m: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['belongsToMany'],
|
||||||
schemaInitialize(schema: ISchema, { readPretty, block }) {
|
schemaInitialize(schema: ISchema, { readPretty, block }) {
|
||||||
if (block === 'Form') {
|
if (block === 'Form') {
|
||||||
if (schema['x-component'] === 'AssociationSelect') {
|
if (schema['x-component'] === 'AssociationSelect') {
|
||||||
|
@ -50,6 +50,7 @@ export const m2o: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['belongsTo'],
|
||||||
schemaInitialize(schema: ISchema, { block, readPretty }) {
|
schemaInitialize(schema: ISchema, { block, readPretty }) {
|
||||||
if (block === 'Form') {
|
if (block === 'Form') {
|
||||||
if (schema['x-component'] === 'AssociationSelect') {
|
if (schema['x-component'] === 'AssociationSelect') {
|
||||||
|
@ -17,6 +17,7 @@ export const markdown: IField = {
|
|||||||
'x-component': 'Markdown',
|
'x-component': 'Markdown',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['text'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -21,6 +21,7 @@ export const multipleSelect: IField = {
|
|||||||
enum: [],
|
enum: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['array'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -22,6 +22,7 @@ export const number: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['double'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -50,6 +50,7 @@ export const o2m: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['hasMany'],
|
||||||
schemaInitialize(schema: ISchema, { field, block, readPretty }) {
|
schemaInitialize(schema: ISchema, { field, block, readPretty }) {
|
||||||
if (block === 'Form') {
|
if (block === 'Form') {
|
||||||
if (schema['x-component'] === 'TableField') {
|
if (schema['x-component'] === 'TableField') {
|
||||||
|
@ -117,6 +117,7 @@ export const o2o: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['hasOne'],
|
||||||
schemaInitialize(schema: ISchema, { field, block, readPretty, action }) {
|
schemaInitialize(schema: ISchema, { field, block, readPretty, action }) {
|
||||||
internalSchameInitialize(schema, { field, block, readPretty, action });
|
internalSchameInitialize(schema, { field, block, readPretty, action });
|
||||||
if (['Table', 'Kanban'].includes(block)) {
|
if (['Table', 'Kanban'].includes(block)) {
|
||||||
|
@ -18,6 +18,7 @@ export const password: IField = {
|
|||||||
'x-component': 'Password',
|
'x-component': 'Password',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes:['password'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -13,7 +13,7 @@ registerValidateRules({
|
|||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: `${i18n.t('The field value cannot be greater than ')}${maxValue * 100}%`,
|
message: `${i18n.t('The field value cannot be greater than ')}${maxValue * 100}%`,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ registerValidateRules({
|
|||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: `${i18n.t('The field value cannot be less than ')}${minValue * 100}%`,
|
message: `${i18n.t('The field value cannot be less than ')}${minValue * 100}%`,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,12 +36,12 @@ registerValidateRules({
|
|||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: `${i18n.t('The field value is not an integer number')}`,
|
message: `${i18n.t('The field value is not an integer number')}`,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// registerValidateFormats({
|
// registerValidateFormats({
|
||||||
// percentInteger: /^(\d+)(.\d{0,2})?$/,
|
// percentInteger: /^(\d+)(.\d{0,2})?$/,
|
||||||
@ -68,6 +68,7 @@ export const percent: IField = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['float'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
@ -104,7 +105,9 @@ export const percent: IField = {
|
|||||||
'x-reactions': `{{(field) => {
|
'x-reactions': `{{(field) => {
|
||||||
const targetValue = field.query('.minimum').value();
|
const targetValue = field.query('.minimum').value();
|
||||||
field.selfErrors =
|
field.selfErrors =
|
||||||
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Maximum must greater than minimum')}' : ''
|
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t(
|
||||||
|
'Maximum must greater than minimum',
|
||||||
|
)}' : ''
|
||||||
}}}`,
|
}}}`,
|
||||||
},
|
},
|
||||||
minValue: {
|
minValue: {
|
||||||
@ -119,7 +122,9 @@ export const percent: IField = {
|
|||||||
dependencies: ['.maximum'],
|
dependencies: ['.maximum'],
|
||||||
fulfill: {
|
fulfill: {
|
||||||
state: {
|
state: {
|
||||||
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Minimum must less than maximum')}' : ''}}`,
|
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t(
|
||||||
|
'Minimum must less than maximum',
|
||||||
|
)}' : ''}}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -132,10 +137,12 @@ export const percent: IField = {
|
|||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
allowClear: true,
|
allowClear: true,
|
||||||
},
|
},
|
||||||
enum: [{
|
enum: [
|
||||||
label: '{{ t("Integer") }}',
|
{
|
||||||
value: 'Integer',
|
label: '{{ t("Integer") }}',
|
||||||
}]
|
value: 'Integer',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
pattern: {
|
pattern: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -145,8 +152,8 @@ export const percent: IField = {
|
|||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
prefix: '/',
|
prefix: '/',
|
||||||
suffix: '/',
|
suffix: '/',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
@ -21,6 +21,7 @@ export const phone: IField = {
|
|||||||
// 'x-validator': 'phone',
|
// 'x-validator': 'phone',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['string'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -17,6 +17,7 @@ export const radioGroup: IField = {
|
|||||||
'x-component': 'Radio.Group',
|
'x-component': 'Radio.Group',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['string'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -18,6 +18,7 @@ export const richText: IField = {
|
|||||||
'x-component': 'RichText',
|
'x-component': 'RichText',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['text'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -18,6 +18,7 @@ export const select: IField = {
|
|||||||
enum: [],
|
enum: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['string'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -20,6 +20,7 @@ export const subTable: IField = {
|
|||||||
'x-component-props': {},
|
'x-component-props': {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['hasMany'],
|
||||||
schemaInitialize(schema: ISchema, { field, readPretty }) {
|
schemaInitialize(schema: ISchema, { field, readPretty }) {
|
||||||
const association = `${field.collectionName}.${field.name}`;
|
const association = `${field.collectionName}.${field.name}`;
|
||||||
schema['type'] = 'void';
|
schema['type'] = 'void';
|
||||||
|
@ -18,6 +18,7 @@ export const textarea: IField = {
|
|||||||
'x-component': 'Input.TextArea',
|
'x-component': 'Input.TextArea',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['text'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -16,6 +16,7 @@ export const time: IField = {
|
|||||||
'x-component': 'TimePicker',
|
'x-component': 'TimePicker',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['time'],
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: true,
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
@ -20,6 +20,7 @@ export const updatedAt: IField = {
|
|||||||
'x-read-pretty': true,
|
'x-read-pretty': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['date'],
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
...dateTimeProps,
|
...dateTimeProps,
|
||||||
|
@ -27,6 +27,7 @@ export const updatedBy: IField = {
|
|||||||
'x-read-pretty': true,
|
'x-read-pretty': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
availableTypes: ['belongsTo'],
|
||||||
properties: {
|
properties: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
},
|
},
|
||||||
|
@ -0,0 +1,216 @@
|
|||||||
|
import { Cascader } from '@formily/antd';
|
||||||
|
import { useField, useForm } from '@formily/react';
|
||||||
|
import { Input, Select, Spin, Table, Tag } from 'antd';
|
||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ResourceActionContext, useCompile } from '../../../';
|
||||||
|
import { useAPIClient } from '../../../api-client';
|
||||||
|
import { getOptions } from '../../Configuration/interfaces';
|
||||||
|
import { useCollectionManager } from '../../hooks/useCollectionManager';
|
||||||
|
|
||||||
|
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) => v.children.length > 0);
|
||||||
|
};
|
||||||
|
const PreviewCom = (props) => {
|
||||||
|
const { name, sources, viewName, schema } = props;
|
||||||
|
const { data: fields } = useContext(ResourceActionContext);
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dataSource, setDataSource] = useState([]);
|
||||||
|
const [sourceFields, setSourceFields] = useState([]);
|
||||||
|
const field: any = useField();
|
||||||
|
const form = useForm();
|
||||||
|
const { getCollection } = useCollectionManager();
|
||||||
|
const compile = useCompile();
|
||||||
|
const initOptions = getOptions().filter((v) => !['relation', 'systemInfo'].includes(v.key));
|
||||||
|
useEffect(() => {
|
||||||
|
const data = [];
|
||||||
|
sources.forEach((item) => {
|
||||||
|
const collection = getCollection(item);
|
||||||
|
const children = collection.fields?.map((v) => {
|
||||||
|
return { value: v.name, label: v.uiSchema?.title };
|
||||||
|
});
|
||||||
|
data.push({
|
||||||
|
value: item,
|
||||||
|
label: collection.title,
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setSourceFields(data);
|
||||||
|
}, [sources, name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (name) {
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.resource(`dbViews`)
|
||||||
|
.get({ filterByTk: viewName, schema })
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (data) {
|
||||||
|
setLoading(false);
|
||||||
|
setDataSource([]);
|
||||||
|
const fieldsData = Object.values(data?.data?.fields)?.map((v: any) => {
|
||||||
|
if (v.source) {
|
||||||
|
return v;
|
||||||
|
} else {
|
||||||
|
return fields?.data.find((h) => h.name === v.name) || v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
field.value = fieldsData;
|
||||||
|
setDataSource(fieldsData);
|
||||||
|
form.setValuesIn('sources', data.data?.sources);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const handleFieldChange = (record, index) => {
|
||||||
|
dataSource.splice(index, 1, record);
|
||||||
|
setDataSource(dataSource);
|
||||||
|
field.value = dataSource.map((v) => {
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
source: typeof v.source === 'string' ? v.source : v.source?.join('.'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('Field name'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Field source'),
|
||||||
|
dataIndex: 'source',
|
||||||
|
key: 'source',
|
||||||
|
width: 200,
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return (
|
||||||
|
<Cascader
|
||||||
|
defaultValue={typeof text === 'string' ? text?.split('.') : text}
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={compile(sourceFields)}
|
||||||
|
onChange={(value, selectedOptions) => {
|
||||||
|
handleFieldChange({ ...record, source: value }, index);
|
||||||
|
}}
|
||||||
|
placeholder={t('Select field source')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Field type'),
|
||||||
|
dataIndex: 'type',
|
||||||
|
width: 140,
|
||||||
|
key: 'type',
|
||||||
|
render: (text, _, index) => {
|
||||||
|
const item = dataSource[index];
|
||||||
|
return item?.source || !item?.possibleTypes ? (
|
||||||
|
<Tag>{text}</Tag>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
defaultValue={text}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={
|
||||||
|
item?.possibleTypes.map((v) => {
|
||||||
|
return { label: v, value: v };
|
||||||
|
}) || []
|
||||||
|
}
|
||||||
|
onChange={(value) => handleFieldChange({ ...item, type: value }, index)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Field interface'),
|
||||||
|
dataIndex: 'interface',
|
||||||
|
key: 'interface',
|
||||||
|
width: 150,
|
||||||
|
render: (text, _, index) => {
|
||||||
|
const item = dataSource[index];
|
||||||
|
const data = getInterfaceOptions(initOptions, item.type);
|
||||||
|
return item.source ? (
|
||||||
|
text
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
defaultValue={text}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(value) => handleFieldChange({ ...item, interface: value }, index)}
|
||||||
|
>
|
||||||
|
{data.map((group) => (
|
||||||
|
<Select.OptGroup key={group.key} label={compile(group.label)}>
|
||||||
|
{group.children.map((item) => (
|
||||||
|
<Select.Option key={item.value} value={item.value}>
|
||||||
|
{compile(item.label)}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select.OptGroup>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Field display name'),
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
width: 180,
|
||||||
|
render: (text, record, index) => {
|
||||||
|
const item = dataSource[index];
|
||||||
|
return item.source ? (
|
||||||
|
record?.uiSchema?.title
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
defaultValue={record?.uiSchema?.title}
|
||||||
|
onChange={(e) => handleFieldChange({ ...item, uiSchema: { title: e.target.value } }, index)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{dataSource.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="ant-formily-item-label">
|
||||||
|
<div className="ant-formily-item-label-content">
|
||||||
|
<span>
|
||||||
|
<label>{t('Fields')}</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="ant-formily-item-colon">:</span>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
bordered
|
||||||
|
size={'middle'}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={dataSource}
|
||||||
|
scroll={{ y: 300 }}
|
||||||
|
pagination={false}
|
||||||
|
rowClassName="editable-row"
|
||||||
|
key={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function areEqual(prevProps, nextProps) {
|
||||||
|
return nextProps.name === prevProps.name && nextProps.sources === prevProps.sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviewFields = React.memo(PreviewCom, areEqual);
|
@ -0,0 +1,105 @@
|
|||||||
|
import { RecursionField, useForm } from '@formily/react';
|
||||||
|
import { Spin, Table } from 'antd';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { EllipsisWithTooltip, useCompile } from '../../../';
|
||||||
|
import { useAPIClient } from '../../../api-client';
|
||||||
|
import { useCollectionManager } from '../../hooks/useCollectionManager';
|
||||||
|
|
||||||
|
export const PreviewTable = (props) => {
|
||||||
|
const { name, viewName, schema, fields } = props;
|
||||||
|
const [previewColumns, setPreviewColumns] = useState([]);
|
||||||
|
const [previewData, setPreviewData] = useState([]);
|
||||||
|
const compile = useCompile();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { getCollection, getCollectionField, getInterface } = useCollectionManager();
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = useForm();
|
||||||
|
useEffect(() => {
|
||||||
|
if (name) {
|
||||||
|
getPreviewData();
|
||||||
|
}
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pColumns = formatPreviewColumns(fields);
|
||||||
|
setPreviewColumns(pColumns);
|
||||||
|
}, [form.values.fields]);
|
||||||
|
|
||||||
|
const getPreviewData = () => {
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.resource(`dbViews`)
|
||||||
|
.query({ filterByTk: viewName, schema })
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (data) {
|
||||||
|
setLoading(false);
|
||||||
|
setPreviewData(data?.data || []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPreviewColumns = (data) => {
|
||||||
|
return data
|
||||||
|
.filter((k) => k.source || k.interface)
|
||||||
|
?.map((item) => {
|
||||||
|
const fieldSource = typeof item?.source === 'string' ? item?.source?.split('.') : item?.source;
|
||||||
|
const sourceField = getCollection(fieldSource?.[0])?.fields.find((v) => v.name === fieldSource?.[1])?.uiSchema
|
||||||
|
?.title;
|
||||||
|
const target = sourceField || item?.uiSchema?.title || item.name;
|
||||||
|
const schema: any = item.source
|
||||||
|
? getCollectionField(typeof item.source === 'string' ? item.source : item.source.join('.'))?.uiSchema
|
||||||
|
: getInterface(item.interface)?.default?.uiSchema;
|
||||||
|
return {
|
||||||
|
title: compile(target),
|
||||||
|
dataIndex: item.name,
|
||||||
|
key: item.name,
|
||||||
|
width: 200,
|
||||||
|
render: (v, record, index) => {
|
||||||
|
const content = record[item.name];
|
||||||
|
const objSchema: any = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
[item.name]: { ...schema, default: content, 'x-read-pretty': true, title: null },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<EllipsisWithTooltip ellipsis={true}>
|
||||||
|
<RecursionField schema={objSchema} name={index} onlyRenderProperties />
|
||||||
|
</EllipsisWithTooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewColumns?.length > 0 && [
|
||||||
|
<div className="ant-formily-item-label" style={{ marginTop: 24 }}>
|
||||||
|
<div className="ant-formily-item-label-content">
|
||||||
|
<span>
|
||||||
|
<label>{t('Preview')}</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="ant-formily-item-colon">:</span>
|
||||||
|
</div>,
|
||||||
|
<Table
|
||||||
|
size={'middle'}
|
||||||
|
pagination={false}
|
||||||
|
bordered
|
||||||
|
columns={previewColumns}
|
||||||
|
dataSource={previewData}
|
||||||
|
scroll={{ x: 1000, y: 300 }}
|
||||||
|
key={name}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
export * from './calendar';
|
export * from './calendar';
|
||||||
export * from './general';
|
export * from './general';
|
||||||
export * from './tree';
|
export * from './tree';
|
||||||
|
export * from './view';
|
||||||
|
@ -14,6 +14,8 @@ export interface ICollectionTemplate {
|
|||||||
configurableProperties?: Record<string, ISchema>;
|
configurableProperties?: Record<string, ISchema>;
|
||||||
/** 当前模板可用的字段类型 */
|
/** 当前模板可用的字段类型 */
|
||||||
availableFieldInterfaces?: AvailableFieldInterfacesInclude | AvailableFieldInterfacesExclude;
|
availableFieldInterfaces?: AvailableFieldInterfacesInclude | AvailableFieldInterfacesExclude;
|
||||||
|
/** 是否分割线 */
|
||||||
|
divider?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AvailableFieldInterfacesInclude {
|
interface AvailableFieldInterfacesInclude {
|
||||||
@ -24,7 +26,6 @@ interface AvailableFieldInterfacesExclude {
|
|||||||
exclude?: any[];
|
exclude?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface CollectionOptions {
|
interface CollectionOptions {
|
||||||
/**
|
/**
|
||||||
* 自动生成 id
|
* 自动生成 id
|
||||||
|
106
packages/core/client/src/collection-manager/templates/view.tsx
Normal file
106
packages/core/client/src/collection-manager/templates/view.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { getConfigurableProperties } from './properties';
|
||||||
|
import { ICollectionTemplate } from './types';
|
||||||
|
import { PreviewFields } from './components/PreviewFields';
|
||||||
|
import { PreviewTable } from './components/PreviewTable';
|
||||||
|
|
||||||
|
|
||||||
|
export const view: ICollectionTemplate = {
|
||||||
|
name: 'view',
|
||||||
|
title: '{{t("Connect to database view")}}',
|
||||||
|
order: 4,
|
||||||
|
color: 'yellow',
|
||||||
|
default: {
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
divider: true,
|
||||||
|
configurableProperties: {
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
title: '{{ t("Collection display name") }}',
|
||||||
|
required: true,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: '{{t("Connect to database view")}}',
|
||||||
|
type: 'single',
|
||||||
|
required: true,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Select',
|
||||||
|
'x-reactions': ['{{useAsyncDataSource(loadDBViews)}}'],
|
||||||
|
'x-disabled': '{{ !createOnly }}',
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
'x-hidden': true,
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['name'],
|
||||||
|
when: "{{isPG}}",
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
value: "{{$deps[0].split('_')?.[0]}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
otherwise: {
|
||||||
|
state: {
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
viewName: {
|
||||||
|
type: 'string',
|
||||||
|
'x-hidden': true,
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['name'],
|
||||||
|
when: "{{isPG}}",
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
value: '{{$deps[0].match(/^([^_]+)_(.*)$/)?.[2]}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
otherwise: {
|
||||||
|
state: {
|
||||||
|
value: '{{$deps[0]}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
type: 'array',
|
||||||
|
title: '{{ t("Source collections") }}',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Select',
|
||||||
|
'x-component-props': {
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
|
'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'],
|
||||||
|
'x-disabled': true,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
type: 'array',
|
||||||
|
'x-component': PreviewFields,
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['name'],
|
||||||
|
fulfill: {
|
||||||
|
schema: {
|
||||||
|
'x-component-props': '{{$form.values}}', //任意层次属性都支持表达式
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
type: 'object',
|
||||||
|
'x-component': PreviewTable,
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['name','fields'],
|
||||||
|
fulfill: {
|
||||||
|
schema: {
|
||||||
|
'x-component-props': '{{$form.values}}', //任意层次属性都支持表达式
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...getConfigurableProperties('category'),
|
||||||
|
},
|
||||||
|
};
|
@ -180,6 +180,10 @@ export default {
|
|||||||
"Collection template": "Collection template",
|
"Collection template": "Collection template",
|
||||||
"Calendar collection": "Calendar collection",
|
"Calendar collection": "Calendar collection",
|
||||||
"General collection": "General collection",
|
"General collection": "General collection",
|
||||||
|
"Connect to database view":"Connect to database view",
|
||||||
|
"Source collections":"Source collections",
|
||||||
|
"Field source":"Field source",
|
||||||
|
"Preview":"Preview",
|
||||||
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.",
|
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.",
|
||||||
"Storage type": "Storage type",
|
"Storage type": "Storage type",
|
||||||
"Edit": "Edit",
|
"Edit": "Edit",
|
||||||
|
@ -171,6 +171,10 @@ export default {
|
|||||||
"Collection template":"データテーブルテンプレート",
|
"Collection template":"データテーブルテンプレート",
|
||||||
"Calendar collection":"カレンダデータテーブル",
|
"Calendar collection":"カレンダデータテーブル",
|
||||||
"General collection":"一般データテーブル",
|
"General collection":"一般データテーブル",
|
||||||
|
"Connect to database view":"ビューに接続",
|
||||||
|
"Source collections":"ソースデータセット",
|
||||||
|
"Field source":"ソースフィールド",
|
||||||
|
"Preview":"プレビュー",
|
||||||
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "ランダムに生成され、変更可能です。 アルファベット、数字、アンダースコアをサポートし、アルファベットから始まる必要があります。",
|
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "ランダムに生成され、変更可能です。 アルファベット、数字、アンダースコアをサポートし、アルファベットから始まる必要があります。",
|
||||||
"Storage type": "ストレージタイプ",
|
"Storage type": "ストレージタイプ",
|
||||||
"Edit": "編集",
|
"Edit": "編集",
|
||||||
|
@ -188,6 +188,10 @@ export default {
|
|||||||
"Collection template": "数据表模板",
|
"Collection template": "数据表模板",
|
||||||
"Calendar collection": "日历数据表",
|
"Calendar collection": "日历数据表",
|
||||||
"General collection": "普通数据表",
|
"General collection": "普通数据表",
|
||||||
|
"Connect to database view":"连接数据库视图",
|
||||||
|
"Source collections":"来源数据表",
|
||||||
|
"Field source":"来源字段",
|
||||||
|
"Preview":"预览",
|
||||||
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。",
|
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。",
|
||||||
"Storage type": "存储类型",
|
"Storage type": "存储类型",
|
||||||
"Types will be used in database": "数据库使用的类型",
|
"Types will be used in database": "数据库使用的类型",
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { useCollection } from '../../';
|
||||||
|
|
||||||
// 日历的操作配置
|
// 日历的操作配置
|
||||||
export const CalendarActionInitializers = {
|
export const CalendarActionInitializers = {
|
||||||
title: '{{t("Configure actions")}}',
|
title: '{{t("Configure actions")}}',
|
||||||
@ -72,6 +74,10 @@ export const CalendarActionInitializers = {
|
|||||||
skipScopeCheck: true,
|
skipScopeCheck: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { useCollection } from '../..';
|
||||||
|
|
||||||
// 表单的操作配置
|
// 表单的操作配置
|
||||||
export const CalendarFormActionInitializers = {
|
export const CalendarFormActionInitializers = {
|
||||||
title: '{{t("Configure actions")}}',
|
title: '{{t("Configure actions")}}',
|
||||||
@ -21,6 +23,10 @@ export const CalendarFormActionInitializers = {
|
|||||||
type: 'primary',
|
type: 'primary',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -30,6 +36,10 @@ export const CalendarFormActionInitializers = {
|
|||||||
'x-component': 'Action',
|
'x-component': 'Action',
|
||||||
'x-decorator': 'ACLActionProvider',
|
'x-decorator': 'ACLActionProvider',
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -39,6 +49,10 @@ export const CalendarFormActionInitializers = {
|
|||||||
'x-component': 'Action',
|
'x-component': 'Action',
|
||||||
'x-decorator': 'ACLActionProvider',
|
'x-decorator': 'ACLActionProvider',
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -130,6 +144,10 @@ export const CalendarFormActionInitializers = {
|
|||||||
useProps: '{{ useCustomizeUpdateActionProps }}',
|
useProps: '{{ useCustomizeUpdateActionProps }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -153,6 +171,10 @@ export const CalendarFormActionInitializers = {
|
|||||||
useProps: '{{ useCustomizeRequestActionProps }}',
|
useProps: '{{ useCustomizeRequestActionProps }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { useCollection } from '../../';
|
||||||
|
|
||||||
export const KanbanActionInitializers = {
|
export const KanbanActionInitializers = {
|
||||||
title: "{{t('Configure actions')}}",
|
title: "{{t('Configure actions')}}",
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
@ -28,6 +30,10 @@ export const KanbanActionInitializers = {
|
|||||||
skipScopeCheck: true,
|
skipScopeCheck: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { useCollection } from '../..';
|
||||||
|
|
||||||
// 表单的操作配置
|
// 表单的操作配置
|
||||||
export const ReadPrettyFormActionInitializers = {
|
export const ReadPrettyFormActionInitializers = {
|
||||||
title: '{{t("Configure actions")}}',
|
title: '{{t("Configure actions")}}',
|
||||||
@ -21,6 +23,10 @@ export const ReadPrettyFormActionInitializers = {
|
|||||||
type: 'primary',
|
type: 'primary',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -30,6 +36,10 @@ export const ReadPrettyFormActionInitializers = {
|
|||||||
'x-component': 'Action',
|
'x-component': 'Action',
|
||||||
'x-decorator': 'ACLActionProvider',
|
'x-decorator': 'ACLActionProvider',
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -122,6 +132,10 @@ export const ReadPrettyFormActionInitializers = {
|
|||||||
useProps: '{{ useCustomizeUpdateActionProps }}',
|
useProps: '{{ useCustomizeUpdateActionProps }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -145,6 +159,10 @@ export const ReadPrettyFormActionInitializers = {
|
|||||||
useProps: '{{ useCustomizeRequestActionProps }}',
|
useProps: '{{ useCustomizeRequestActionProps }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -166,8 +166,11 @@ export const RecordBlockInitializers = (props: any) => {
|
|||||||
const { insertPosition, component, actionInitializers } = props;
|
const { insertPosition, component, actionInitializers } = props;
|
||||||
const collection = useCollection();
|
const collection = useCollection();
|
||||||
const { getChildrenCollections } = useCollectionManager();
|
const { getChildrenCollections } = useCollectionManager();
|
||||||
const childrenCollections = getChildrenCollections(collection.name);
|
const formChildrenCollections = getChildrenCollections(collection.name);
|
||||||
const hasChildCollection = childrenCollections?.length > 0;
|
const hasFormChildCollection = formChildrenCollections?.length > 0;
|
||||||
|
const detailChildrenCollections = getChildrenCollections(collection.name, true);
|
||||||
|
const hasDetailChildCollection = detailChildrenCollections?.length > 0;
|
||||||
|
const modifyFlag = (collection as any).template !== 'view';
|
||||||
return (
|
return (
|
||||||
<SchemaInitializer.Button
|
<SchemaInitializer.Button
|
||||||
wrap={gridRowColWrap}
|
wrap={gridRowColWrap}
|
||||||
@ -179,36 +182,43 @@ export const RecordBlockInitializers = (props: any) => {
|
|||||||
{
|
{
|
||||||
type: 'itemGroup',
|
type: 'itemGroup',
|
||||||
title: '{{t("Current record blocks")}}',
|
title: '{{t("Current record blocks")}}',
|
||||||
children: hasChildCollection
|
children: [
|
||||||
? [
|
hasDetailChildCollection
|
||||||
{
|
? {
|
||||||
key: 'details',
|
key: 'details',
|
||||||
type: 'subMenu',
|
type: 'subMenu',
|
||||||
title: '{{t("Details")}}',
|
title: '{{t("Details")}}',
|
||||||
children: useDetailCollections({ ...props, childrenCollections, collection }),
|
children: useDetailCollections({
|
||||||
},
|
...props,
|
||||||
{
|
childrenCollections: detailChildrenCollections,
|
||||||
key: 'form',
|
collection,
|
||||||
type: 'subMenu',
|
}),
|
||||||
title: '{{t("Form")}}',
|
}
|
||||||
children: useFormCollections({ ...props, childrenCollections, collection }),
|
: {
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
key: 'details',
|
key: 'details',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
title: '{{t("Details")}}',
|
title: '{{t("Details")}}',
|
||||||
component: 'RecordReadPrettyFormBlockInitializer',
|
component: 'RecordReadPrettyFormBlockInitializer',
|
||||||
actionInitializers,
|
actionInitializers,
|
||||||
},
|
},
|
||||||
{
|
hasFormChildCollection
|
||||||
|
? {
|
||||||
|
key: 'form',
|
||||||
|
type: 'subMenu',
|
||||||
|
title: '{{t("Form")}}',
|
||||||
|
children: useFormCollections({
|
||||||
|
...props,
|
||||||
|
childrenCollections: formChildrenCollections,
|
||||||
|
collection,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: modifyFlag && {
|
||||||
key: 'form',
|
key: 'form',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
title: '{{t("Form")}}',
|
title: '{{t("Form")}}',
|
||||||
component: 'RecordFormBlockInitializer',
|
component: 'RecordFormBlockInitializer',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'itemGroup',
|
type: 'itemGroup',
|
||||||
|
@ -51,7 +51,8 @@ export const TableActionColumnInitializers = (props: any) => {
|
|||||||
const { refresh } = useDesignable();
|
const { refresh } = useDesignable();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const collection = useCollection();
|
const collection = useCollection();
|
||||||
const { treeTable } = fieldSchema?.parent?.parent['x-decorator-props']||{};
|
const { treeTable } = fieldSchema?.parent?.parent['x-decorator-props'] || {};
|
||||||
|
const modifyFlag = (collection as any).template !== 'view';
|
||||||
return (
|
return (
|
||||||
<SchemaInitializer.Button
|
<SchemaInitializer.Button
|
||||||
insertPosition={'beforeEnd'}
|
insertPosition={'beforeEnd'}
|
||||||
@ -98,8 +99,12 @@ export const TableActionColumnInitializers = (props: any) => {
|
|||||||
'x-action': 'update',
|
'x-action': 'update',
|
||||||
'x-decorator': 'ACLActionProvider',
|
'x-decorator': 'ACLActionProvider',
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
modifyFlag && {
|
||||||
type: 'item',
|
type: 'item',
|
||||||
title: t('Delete'),
|
title: t('Delete'),
|
||||||
component: 'DestroyActionInitializer',
|
component: 'DestroyActionInitializer',
|
||||||
@ -202,6 +207,10 @@ export const TableActionColumnInitializers = (props: any) => {
|
|||||||
useProps: '{{ useCustomizeUpdateActionProps }}',
|
useProps: '{{ useCustomizeUpdateActionProps }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -224,6 +233,10 @@ export const TableActionColumnInitializers = (props: any) => {
|
|||||||
useProps: '{{ useCustomizeRequestActionProps }}',
|
useProps: '{{ useCustomizeRequestActionProps }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -32,6 +32,10 @@ export const TableActionInitializers = {
|
|||||||
skipScopeCheck: true,
|
skipScopeCheck: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -41,6 +45,10 @@ export const TableActionInitializers = {
|
|||||||
'x-align': 'right',
|
'x-align': 'right',
|
||||||
'x-decorator': 'ACLActionProvider',
|
'x-decorator': 'ACLActionProvider',
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -60,7 +68,7 @@ export const TableActionInitializers = {
|
|||||||
visible: () => {
|
visible: () => {
|
||||||
const schema = useFieldSchema();
|
const schema = useFieldSchema();
|
||||||
const collection = useCollection();
|
const collection = useCollection();
|
||||||
const { treeTable } = schema?.parent?.['x-decorator-props']||{};
|
const { treeTable } = schema?.parent?.['x-decorator-props'] || {};
|
||||||
return collection.tree && treeTable !== false;
|
return collection.tree && treeTable !== false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -68,6 +76,10 @@ export const TableActionInitializers = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -82,9 +94,17 @@ export const TableActionInitializers = {
|
|||||||
)?.[1];
|
)?.[1];
|
||||||
return resultSchema;
|
return resultSchema;
|
||||||
},
|
},
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'subMenu',
|
type: 'subMenu',
|
||||||
@ -135,6 +155,10 @@ export const TableActionInitializers = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
visible: () => {
|
||||||
|
const collection = useCollection();
|
||||||
|
return (collection as any).template !== 'view';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { TableOutlined } from '@ant-design/icons';
|
import { TableOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useCollectionManager } from "../../collection-manager";
|
import { useCollectionManager } from '../../collection-manager';
|
||||||
import { createDetailsBlockSchema } from "../utils";
|
import { createDetailsBlockSchema } from '../utils';
|
||||||
import { DataBlockInitializer } from "./DataBlockInitializer";
|
import { DataBlockInitializer } from './DataBlockInitializer';
|
||||||
|
|
||||||
export const DetailsBlockInitializer = (props) => {
|
export const DetailsBlockInitializer = (props) => {
|
||||||
const { insert } = props;
|
const { insert } = props;
|
||||||
@ -15,7 +15,11 @@ export const DetailsBlockInitializer = (props) => {
|
|||||||
componentType={'Details'}
|
componentType={'Details'}
|
||||||
onCreateBlockSchema={async ({ item }) => {
|
onCreateBlockSchema={async ({ item }) => {
|
||||||
const collection = getCollection(item.name);
|
const collection = getCollection(item.name);
|
||||||
const schema = createDetailsBlockSchema({ collection: item.name, rowKey: collection.filterTargetKey || 'id' });
|
const schema = createDetailsBlockSchema({
|
||||||
|
collection: item.name,
|
||||||
|
rowKey: collection.filterTargetKey || 'id',
|
||||||
|
actionInitializers: collection.template !== 'view' && 'DetailsActionInitializers',
|
||||||
|
});
|
||||||
insert(schema);
|
insert(schema);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { TableOutlined } from '@ant-design/icons';
|
import { TableOutlined } from '@ant-design/icons';
|
||||||
|
import { useCollectionManager } from '../../collection-manager';
|
||||||
import { useCollectionManager } from "../../collection-manager";
|
import { DataBlockInitializer } from './DataBlockInitializer';
|
||||||
import { DataBlockInitializer } from "./DataBlockInitializer";
|
import { createTableBlockSchema } from '../utils';
|
||||||
import { createTableBlockSchema } from "../utils";
|
|
||||||
|
|
||||||
export const TableBlockInitializer = (props) => {
|
export const TableBlockInitializer = (props) => {
|
||||||
const { insert } = props;
|
const { insert } = props;
|
||||||
@ -15,7 +14,10 @@ export const TableBlockInitializer = (props) => {
|
|||||||
componentType={'Table'}
|
componentType={'Table'}
|
||||||
onCreateBlockSchema={async ({ item }) => {
|
onCreateBlockSchema={async ({ item }) => {
|
||||||
const collection = getCollection(item.name);
|
const collection = getCollection(item.name);
|
||||||
const schema = createTableBlockSchema({ collection: item.name, rowKey: collection.filterTargetKey || 'id' });
|
const schema = createTableBlockSchema({
|
||||||
|
collection: item.name,
|
||||||
|
rowKey: collection.filterTargetKey || 'id',
|
||||||
|
});
|
||||||
insert(schema);
|
insert(schema);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -705,6 +705,8 @@ export const useCollectionDataSourceItems = (componentName) => {
|
|||||||
return false;
|
return false;
|
||||||
} else if (item.autoGenId === false && !item.fields.find((v) => v.primaryKey)) {
|
} else if (item.autoGenId === false && !item.fields.find((v) => v.primaryKey)) {
|
||||||
return false;
|
return false;
|
||||||
|
} else if (['Kanban', 'FormItem'].includes(componentName) && item.template === 'view') {
|
||||||
|
return false;
|
||||||
} else {
|
} else {
|
||||||
return b && !(item?.isThrough && item?.autoCreate);
|
return b && !(item?.isThrough && item?.autoCreate);
|
||||||
}
|
}
|
||||||
|
@ -311,6 +311,20 @@ describe('repository.update', () => {
|
|||||||
name: 'post1',
|
name: 'post1',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await User.repository.update({
|
||||||
|
filterByTk: user.id,
|
||||||
|
values: {
|
||||||
|
posts: [{ name: 'post2' }, { name: 'post3' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated2 = await User.repository.findOne({
|
||||||
|
filterByTk: user.id,
|
||||||
|
appends: ['posts'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated2.posts.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update2', async () => {
|
it('update2', async () => {
|
||||||
|
13
packages/core/database/src/__tests__/sql-parser.test.ts
Normal file
13
packages/core/database/src/__tests__/sql-parser.test.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import sqlParser from '../sql-parser';
|
||||||
|
|
||||||
|
describe('sql parser', () => {
|
||||||
|
it('should parse sql', function () {
|
||||||
|
const sql = `select users.id as id from users`;
|
||||||
|
const { ast } = sqlParser.parse(sql);
|
||||||
|
const columns = ast.columns;
|
||||||
|
const firstColumn = columns[0];
|
||||||
|
|
||||||
|
expect(firstColumn['expr']['table']).toEqual('users');
|
||||||
|
expect(firstColumn['expr']['column']).toEqual('id');
|
||||||
|
});
|
||||||
|
});
|
34
packages/core/database/src/__tests__/view/list-view.test.ts
Normal file
34
packages/core/database/src/__tests__/view/list-view.test.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Database, mockDatabase } from '@nocobase/database';
|
||||||
|
|
||||||
|
describe('list view', () => {
|
||||||
|
let db: Database;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = mockDatabase({
|
||||||
|
tablePrefix: '',
|
||||||
|
});
|
||||||
|
await db.clean({ drop: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list view', async () => {
|
||||||
|
const dropViewSQL1 = `DROP VIEW IF EXISTS test1`;
|
||||||
|
await db.sequelize.query(dropViewSQL1);
|
||||||
|
|
||||||
|
const dropViewSQL2 = `DROP VIEW IF EXISTS test2`;
|
||||||
|
await db.sequelize.query(dropViewSQL2);
|
||||||
|
|
||||||
|
const sql1 = `CREATE VIEW test1 AS SELECT 1`;
|
||||||
|
const sql2 = `CREATE VIEW test2 AS SELECT 2`;
|
||||||
|
|
||||||
|
await db.sequelize.query(sql1);
|
||||||
|
await db.sequelize.query(sql2);
|
||||||
|
|
||||||
|
const results = await db.queryInterface.listViews();
|
||||||
|
expect(results.find((item) => item.name === 'test1')).toBeTruthy();
|
||||||
|
expect(results.find((item) => item.name === 'test2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,199 @@
|
|||||||
|
import { Database, mockDatabase } from '@nocobase/database';
|
||||||
|
import { uid } from '@nocobase/utils';
|
||||||
|
import { ViewCollection } from '../../view-collection';
|
||||||
|
|
||||||
|
describe('create view', () => {
|
||||||
|
let db: Database;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = mockDatabase({
|
||||||
|
tablePrefix: '',
|
||||||
|
});
|
||||||
|
await db.clean({ drop: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create view collection in difference schema', async () => {
|
||||||
|
if (!db.inDialect('postgres')) return;
|
||||||
|
const schemaName = `t_${uid(6)}`;
|
||||||
|
const testSchemaSql = `CREATE SCHEMA IF NOT EXISTS ${schemaName};`;
|
||||||
|
await db.sequelize.query(testSchemaSql);
|
||||||
|
|
||||||
|
const viewName = 'test_view';
|
||||||
|
|
||||||
|
const viewSQL = `CREATE OR REPLACE VIEW ${schemaName}.test_view AS SELECT 1+1 as result`;
|
||||||
|
await db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
const viewCollection = db.collection({
|
||||||
|
name: viewName,
|
||||||
|
schema: schemaName,
|
||||||
|
view: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'result',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await viewCollection.repository.find();
|
||||||
|
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create view collection', async () => {
|
||||||
|
const UserCollection = db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'hasOne',
|
||||||
|
name: 'profile',
|
||||||
|
foreignKey: 'user_id',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ProfileCollection = db.collection({
|
||||||
|
name: 'profiles',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'integer',
|
||||||
|
name: 'age',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'belongsTo',
|
||||||
|
name: 'user',
|
||||||
|
foreignKey: 'user_id',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.sync();
|
||||||
|
|
||||||
|
await UserCollection.repository.create({
|
||||||
|
values: {
|
||||||
|
name: 'foo',
|
||||||
|
profile: {
|
||||||
|
age: 18,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const schema = UserCollection.collectionSchema();
|
||||||
|
const viewName = 'users_with_profile';
|
||||||
|
|
||||||
|
const appendSchema = db.inDialect('postgres') ? `"${schema}".` : '';
|
||||||
|
|
||||||
|
const dropViewSQL = `DROP VIEW IF EXISTS ${appendSchema}${viewName}`;
|
||||||
|
await db.sequelize.query(dropViewSQL);
|
||||||
|
const viewSql = `CREATE VIEW ${appendSchema}${viewName} AS SELECT users.name, profiles.age FROM ${appendSchema}${UserCollection.model.tableName} as users LEFT JOIN ${appendSchema}${ProfileCollection.model.tableName} as profiles ON users.id = profiles.user_id;`;
|
||||||
|
|
||||||
|
await db.sequelize.query(viewSql);
|
||||||
|
|
||||||
|
db.collection({
|
||||||
|
name: viewName,
|
||||||
|
view: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'integer',
|
||||||
|
name: 'age',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const UserWithProfileView = db.getCollection(viewName);
|
||||||
|
expect(UserWithProfileView).toBeInstanceOf(ViewCollection);
|
||||||
|
|
||||||
|
const fooData = await UserWithProfileView.repository.findOne({
|
||||||
|
filter: {
|
||||||
|
name: 'foo',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(fooData);
|
||||||
|
expect(fooData.get('name')).toBe('foo');
|
||||||
|
expect(fooData.get('age')).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync view collection', async () => {
|
||||||
|
const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
|
||||||
|
await db.sequelize.query(dropViewSQL);
|
||||||
|
|
||||||
|
const viewSql = `CREATE VIEW test_view AS SELECT 1+1 as result`;
|
||||||
|
|
||||||
|
await db.sequelize.query(viewSql);
|
||||||
|
const viewCollection = db.collection({
|
||||||
|
name: 'view_collection',
|
||||||
|
viewName: 'test_view',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'result',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jestFn = jest.fn();
|
||||||
|
|
||||||
|
db.on('beforeSync', jestFn);
|
||||||
|
|
||||||
|
await viewCollection.sync();
|
||||||
|
expect(jestFn).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create view collection with source field options', async () => {
|
||||||
|
const UserCollection = db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
type: 'integer',
|
||||||
|
options: { key: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.sync();
|
||||||
|
|
||||||
|
const viewName = 'users_view';
|
||||||
|
|
||||||
|
const dropViewSQL = `DROP VIEW IF EXISTS ${viewName}`;
|
||||||
|
await db.sequelize.query(dropViewSQL);
|
||||||
|
|
||||||
|
const viewSQL = `
|
||||||
|
CREATE VIEW ${viewName} as SELECT users.* FROM ${UserCollection.quotedTableName()} as users
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
// create view collection
|
||||||
|
const ViewCollection = db.collection({
|
||||||
|
name: viewName,
|
||||||
|
view: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
source: 'users.name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewNameField = ViewCollection.getField('name');
|
||||||
|
expect(viewNameField.options.patterns).toEqual(UserCollection.getField('name').options.patterns);
|
||||||
|
});
|
||||||
|
});
|
103
packages/core/database/src/__tests__/view/view-inference.test.ts
Normal file
103
packages/core/database/src/__tests__/view/view-inference.test.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Database, mockDatabase } from '@nocobase/database';
|
||||||
|
import { ViewFieldInference } from '../../view/view-inference';
|
||||||
|
|
||||||
|
describe('view inference', function () {
|
||||||
|
let db: Database;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = mockDatabase({
|
||||||
|
tablePrefix: '',
|
||||||
|
});
|
||||||
|
await db.clean({ drop: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should infer collection fields', async () => {
|
||||||
|
const UserCollection = db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
interface: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'age',
|
||||||
|
type: 'integer',
|
||||||
|
interface: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'profile',
|
||||||
|
type: 'json',
|
||||||
|
interface: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'posts',
|
||||||
|
type: 'hasMany',
|
||||||
|
interface: 'test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const PostCollection = db.collection({
|
||||||
|
name: 'posts',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'string',
|
||||||
|
interface: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user',
|
||||||
|
type: 'belongsTo',
|
||||||
|
interface: 'test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.sync();
|
||||||
|
|
||||||
|
const viewName = 'user_posts';
|
||||||
|
|
||||||
|
const dropViewSQL = `DROP VIEW IF EXISTS ${viewName}`;
|
||||||
|
await db.sequelize.query(dropViewSQL);
|
||||||
|
|
||||||
|
const viewSQL = `
|
||||||
|
CREATE VIEW ${viewName} as SELECT 1 as const_field, users.* FROM ${UserCollection.quotedTableName()} as users
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
const inferredFields = await ViewFieldInference.inferFields({
|
||||||
|
db,
|
||||||
|
viewName,
|
||||||
|
viewSchema: 'public',
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdAt = UserCollection.model.rawAttributes['createdAt'].field;
|
||||||
|
expect(inferredFields[createdAt]['type']).toBe('date');
|
||||||
|
|
||||||
|
if (db.options.dialect == 'sqlite') {
|
||||||
|
expect(inferredFields['name']).toMatchObject({
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expect(inferredFields['name']).toMatchObject({
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
source: 'users.name',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inferredFields['const_field']).toMatchObject({
|
||||||
|
name: 'const_field',
|
||||||
|
type: 'integer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.sequelize.query(dropViewSQL);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,67 @@
|
|||||||
|
import { Database, mockDatabase } from '@nocobase/database';
|
||||||
|
import { uid } from '@nocobase/utils';
|
||||||
|
|
||||||
|
describe('view repository', () => {
|
||||||
|
let db: Database;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = mockDatabase({
|
||||||
|
tablePrefix: '',
|
||||||
|
});
|
||||||
|
await db.clean({ drop: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support find view without primary key', async () => {
|
||||||
|
const UserCollection = await db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.sync();
|
||||||
|
|
||||||
|
await UserCollection.repository.create({
|
||||||
|
values: [{ name: 'a' }, { name: 'b' }, { name: 'c' }, { name: 'd' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewName = `t_${uid(6)}`;
|
||||||
|
const dropSQL = `DROP VIEW IF EXISTS ${viewName};`;
|
||||||
|
await db.sequelize.query(dropSQL);
|
||||||
|
|
||||||
|
const viewSQL = `CREATE VIEW ${viewName} AS select id as aaa, name from ${UserCollection.quotedTableName()}`;
|
||||||
|
|
||||||
|
await db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
const viewCollection = db.collection({
|
||||||
|
name: viewName,
|
||||||
|
view: true,
|
||||||
|
schema: db.inDialect('postgres') ? 'public' : undefined,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'integer',
|
||||||
|
name: 'aaa',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await viewCollection.repository.findAndCount({
|
||||||
|
offset: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results[0].length).toBe(1);
|
||||||
|
expect(results[1]).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
@ -34,6 +34,8 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
|
|||||||
|
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
inherits?: string[] | string;
|
inherits?: string[] | string;
|
||||||
|
viewName?: string;
|
||||||
|
|
||||||
filterTargetKey?: string;
|
filterTargetKey?: string;
|
||||||
fields?: FieldOptions[];
|
fields?: FieldOptions[];
|
||||||
model?: string | ModelStatic<Model>;
|
model?: string | ModelStatic<Model>;
|
||||||
@ -69,7 +71,12 @@ export class Collection<
|
|||||||
repository: Repository<TModelAttributes, TCreationAttributes>;
|
repository: Repository<TModelAttributes, TCreationAttributes>;
|
||||||
|
|
||||||
get filterTargetKey() {
|
get filterTargetKey() {
|
||||||
return lodash.get(this.options, 'filterTargetKey', this.model.primaryKeyAttribute);
|
let targetKey = lodash.get(this.options, 'filterTargetKey', this.model.primaryKeyAttribute);
|
||||||
|
if (!targetKey && this.model.rawAttributes['id']) {
|
||||||
|
return 'id';
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
@ -111,7 +118,11 @@ export class Collection<
|
|||||||
this.modelInit();
|
this.modelInit();
|
||||||
|
|
||||||
this.db.modelCollection.set(this.model, this);
|
this.db.modelCollection.set(this.model, this);
|
||||||
this.db.tableNameCollectionMap.set(this.model.tableName, this);
|
|
||||||
|
// set tableName to collection map
|
||||||
|
// the form of key is `${schema}.${tableName}` if schema exists
|
||||||
|
// otherwise is `${tableName}`
|
||||||
|
this.db.tableNameCollectionMap.set(this.getTableNameWithSchemaAsString(), this);
|
||||||
|
|
||||||
if (!options.inherits) {
|
if (!options.inherits) {
|
||||||
this.setFields(options.fields);
|
this.setFields(options.fields);
|
||||||
@ -141,7 +152,7 @@ export class Collection<
|
|||||||
return this.db.options.underscored ? snakeCase(tName) : tName;
|
return this.db.options.underscored ? snakeCase(tName) : tName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sequelizeModelOptions() {
|
protected sequelizeModelOptions() {
|
||||||
const { name } = this.options;
|
const { name } = this.options;
|
||||||
return {
|
return {
|
||||||
..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']),
|
..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']),
|
||||||
@ -261,6 +272,17 @@ export class Collection<
|
|||||||
this.checkFieldType(name, options);
|
this.checkFieldType(name, options);
|
||||||
|
|
||||||
const { database } = this.context;
|
const { database } = this.context;
|
||||||
|
|
||||||
|
if (options.source) {
|
||||||
|
const [sourceCollectionName, sourceFieldName] = options.source.split('.');
|
||||||
|
const sourceCollection = this.db.collections.get(sourceCollectionName);
|
||||||
|
if (!sourceCollection) {
|
||||||
|
throw new Error(`source collection "${sourceCollectionName}" not found`);
|
||||||
|
}
|
||||||
|
const sourceField = sourceCollection.fields.get(sourceFieldName);
|
||||||
|
options = { ...sourceField.options, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('field.beforeAdd', name, options, { collection: this });
|
this.emit('field.beforeAdd', name, options, { collection: this });
|
||||||
|
|
||||||
const field = database.buildField(
|
const field = database.buildField(
|
||||||
@ -334,9 +356,10 @@ export class Collection<
|
|||||||
|
|
||||||
async removeFromDb(options?: QueryInterfaceDropTableOptions) {
|
async removeFromDb(options?: QueryInterfaceDropTableOptions) {
|
||||||
if (
|
if (
|
||||||
await this.existsInDb({
|
!this.isView() &&
|
||||||
|
(await this.existsInDb({
|
||||||
transaction: options?.transaction,
|
transaction: options?.transaction,
|
||||||
})
|
}))
|
||||||
) {
|
) {
|
||||||
const queryInterface = this.db.sequelize.getQueryInterface();
|
const queryInterface = this.db.sequelize.getQueryInterface();
|
||||||
await queryInterface.dropTable(this.getTableNameWithSchema(), options);
|
await queryInterface.dropTable(this.getTableNameWithSchema(), options);
|
||||||
@ -575,13 +598,23 @@ export class Collection<
|
|||||||
public getTableNameWithSchema() {
|
public getTableNameWithSchema() {
|
||||||
const tableName = this.model.tableName;
|
const tableName = this.model.tableName;
|
||||||
|
|
||||||
if (this.collectionSchema()) {
|
if (this.collectionSchema() && this.db.inDialect('postgres')) {
|
||||||
return this.db.utils.addSchema(tableName, this.collectionSchema());
|
return this.db.utils.addSchema(tableName, this.collectionSchema());
|
||||||
}
|
}
|
||||||
|
|
||||||
return tableName;
|
return tableName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getTableNameWithSchemaAsString() {
|
||||||
|
const tableName = this.model.tableName;
|
||||||
|
|
||||||
|
if (this.collectionSchema() && this.db.inDialect('postgres')) {
|
||||||
|
return `${this.collectionSchema()}.${tableName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tableName;
|
||||||
|
}
|
||||||
|
|
||||||
public quotedTableName() {
|
public quotedTableName() {
|
||||||
return this.db.utils.quoteTable(this.getTableNameWithSchema());
|
return this.db.utils.quoteTable(this.getTableNameWithSchema());
|
||||||
}
|
}
|
||||||
@ -601,4 +634,8 @@ export class Collection<
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isView() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ export default class DatabaseUtils {
|
|||||||
constructor(public db: Database) {}
|
constructor(public db: Database) {}
|
||||||
|
|
||||||
addSchema(tableName, schema?) {
|
addSchema(tableName, schema?) {
|
||||||
|
if (!this.db.inDialect('postgres')) return tableName;
|
||||||
if (this.db.options.schema && !schema) {
|
if (this.db.options.schema && !schema) {
|
||||||
schema = this.db.options.schema;
|
schema = this.db.options.schema;
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ import {
|
|||||||
Sequelize,
|
Sequelize,
|
||||||
SyncOptions,
|
SyncOptions,
|
||||||
Transactionable,
|
Transactionable,
|
||||||
Utils
|
Utils,
|
||||||
} from 'sequelize';
|
} from 'sequelize';
|
||||||
import { SequelizeStorage, Umzug } from 'umzug';
|
import { SequelizeStorage, Umzug } from 'umzug';
|
||||||
import { Collection, CollectionOptions, RepositoryType } from './collection';
|
import { Collection, CollectionOptions, RepositoryType } from './collection';
|
||||||
@ -58,7 +58,7 @@ import {
|
|||||||
SyncListener,
|
SyncListener,
|
||||||
UpdateListener,
|
UpdateListener,
|
||||||
UpdateWithAssociationsListener,
|
UpdateWithAssociationsListener,
|
||||||
ValidateListener
|
ValidateListener,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { patchSequelizeQueryInterface, snakeCase } from './utils';
|
import { patchSequelizeQueryInterface, snakeCase } from './utils';
|
||||||
|
|
||||||
@ -69,6 +69,7 @@ import buildQueryInterface from './query-interface/query-interface-builder';
|
|||||||
import QueryInterface from './query-interface/query-interface';
|
import QueryInterface from './query-interface/query-interface';
|
||||||
import { Logger } from '@nocobase/logger';
|
import { Logger } from '@nocobase/logger';
|
||||||
import { CollectionGroupManager } from './collection-group-manager';
|
import { CollectionGroupManager } from './collection-group-manager';
|
||||||
|
import { ViewCollection } from './view-collection';
|
||||||
|
|
||||||
export interface MergeOptions extends merge.Options {}
|
export interface MergeOptions extends merge.Options {}
|
||||||
|
|
||||||
@ -221,7 +222,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
|||||||
}
|
}
|
||||||
this.options = opts;
|
this.options = opts;
|
||||||
|
|
||||||
this.sequelize = new Sequelize(opts);
|
this.sequelize = new Sequelize(this.sequelizeOptions(this.options));
|
||||||
|
|
||||||
this.queryInterface = buildQueryInterface(this);
|
this.queryInterface = buildQueryInterface(this);
|
||||||
|
|
||||||
@ -297,6 +298,17 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sequelizeOptions(options) {
|
||||||
|
if (options.dialect === 'postgres') {
|
||||||
|
options.hooks = {
|
||||||
|
afterConnect: async (connection) => {
|
||||||
|
await connection.query('SET search_path TO public;');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
initListener() {
|
initListener() {
|
||||||
this.on('beforeDefine', (model, options) => {
|
this.on('beforeDefine', (model, options) => {
|
||||||
if (this.options.underscored) {
|
if (this.options.underscored) {
|
||||||
@ -416,14 +428,21 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
|||||||
return options.inherits && lodash.castArray(options.inherits).length > 0;
|
return options.inherits && lodash.castArray(options.inherits).length > 0;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const collection = hasValidInheritsOptions
|
const hasViewOptions = options.viewName || options.view;
|
||||||
? new InheritedCollection(options, {
|
|
||||||
database: this,
|
|
||||||
})
|
|
||||||
: new Collection(options, {
|
|
||||||
database: this,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const collectionKlass = (() => {
|
||||||
|
if (hasValidInheritsOptions) {
|
||||||
|
return InheritedCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasViewOptions) {
|
||||||
|
return ViewCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Collection;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const collection = new collectionKlass(options, { database: this });
|
||||||
this.collections.set(collection.name, collection);
|
this.collections.set(collection.name, collection);
|
||||||
|
|
||||||
this.emit('afterDefineCollection', collection);
|
this.emit('afterDefineCollection', collection);
|
||||||
@ -647,7 +666,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sequelize.getQueryInterface().dropAllTables(others);
|
await this.queryInterface.dropAll(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectionExistsInDb(name: string, options?: Transactionable) {
|
async collectionExistsInDb(name: string, options?: Transactionable) {
|
||||||
|
@ -157,6 +157,11 @@ export abstract class Field {
|
|||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
if (this.collection.isView()) {
|
||||||
|
this.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const columnReferencesCount = _.filter(
|
const columnReferencesCount = _.filter(
|
||||||
this.collection.model.rawAttributes,
|
this.collection.model.rawAttributes,
|
||||||
(attr) => attr.field == this.columnName(),
|
(attr) => attr.field == this.columnName(),
|
||||||
@ -232,6 +237,7 @@ export abstract class Field {
|
|||||||
if (this.dataType) {
|
if (this.dataType) {
|
||||||
Object.assign(opts, { type: this.dataType });
|
Object.assign(opts, { type: this.dataType });
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ export class JsonbField extends Field {
|
|||||||
return DataTypes.JSON;
|
return DataTypes.JSON;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JsonbFieldOptions extends BaseColumnFieldOptions {
|
export interface JsonbFieldOptions extends BaseColumnFieldOptions {
|
||||||
type: 'jsonb';
|
type: 'jsonb';
|
||||||
}
|
}
|
||||||
|
@ -21,3 +21,5 @@ export * from './update-associations';
|
|||||||
export { snakeCase } from './utils';
|
export { snakeCase } from './utils';
|
||||||
export * from './value-parsers';
|
export * from './value-parsers';
|
||||||
export * from './collection-group-manager';
|
export * from './collection-group-manager';
|
||||||
|
export * from './view-collection';
|
||||||
|
export * from './view/view-inference';
|
||||||
|
@ -14,7 +14,7 @@ export class MockDatabase extends Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigByEnv() {
|
export function getConfigByEnv() {
|
||||||
return {
|
const options = {
|
||||||
username: process.env.DB_USER,
|
username: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_DATABASE,
|
database: process.env.DB_DATABASE,
|
||||||
@ -33,10 +33,14 @@ export function getConfigByEnv() {
|
|||||||
timezone: process.env.DB_TIMEZONE,
|
timezone: process.env.DB_TIMEZONE,
|
||||||
underscored: process.env.DB_UNDERSCORED === 'true',
|
underscored: process.env.DB_UNDERSCORED === 'true',
|
||||||
schema: process.env.DB_SCHEMA !== 'public' ? process.env.DB_SCHEMA : undefined,
|
schema: process.env.DB_SCHEMA !== 'public' ? process.env.DB_SCHEMA : undefined,
|
||||||
dialectOptions: {
|
dialectOptions: {},
|
||||||
application_name: process.env.DB_DIALECT == 'postgres' ? 'nocobase.main' : undefined,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (process.env.DB_DIALECT == 'postgres') {
|
||||||
|
options.dialectOptions['application_name'] = 'nocobase.main';
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
function customLogger(queryString, queryObject) {
|
function customLogger(queryString, queryObject) {
|
||||||
|
@ -151,6 +151,10 @@ export class Model<TModelAttributes extends {} = any, TCreationAttributes extend
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async sync(options) {
|
static async sync(options) {
|
||||||
|
if (this.collection.isView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const model = this as any;
|
const model = this as any;
|
||||||
|
|
||||||
const _schema = model._schema;
|
const _schema = model._schema;
|
||||||
|
@ -109,11 +109,21 @@ export class OptionsParser {
|
|||||||
return filterParams;
|
return filterParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected inheritFromSubQuery(): any {
|
protected inheritFromSubQuery(include): any {
|
||||||
return [
|
include.push([
|
||||||
Sequelize.literal(`(select relname from pg_class where pg_class.oid = "${this.collection.name}".tableoid)`),
|
Sequelize.literal(`(select relname from pg_class where pg_class.oid = "${this.collection.name}".tableoid)`),
|
||||||
'__tableName',
|
'__tableName',
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
include.push([
|
||||||
|
Sequelize.literal(`
|
||||||
|
(SELECT n.nspname
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.oid = "${this.collection.name}".tableoid)
|
||||||
|
`),
|
||||||
|
'__schemaName',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseFields(filterParams: any) {
|
protected parseFields(filterParams: any) {
|
||||||
@ -132,7 +142,7 @@ export class OptionsParser {
|
|||||||
}; // out put all fields by default
|
}; // out put all fields by default
|
||||||
|
|
||||||
if (this.collection.isParent()) {
|
if (this.collection.isParent()) {
|
||||||
attributes.include.push(this.inheritFromSubQuery());
|
this.inheritFromSubQuery(attributes.include);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options?.fields) {
|
if (this.options?.fields) {
|
||||||
@ -146,7 +156,7 @@ export class OptionsParser {
|
|||||||
if (!Array.isArray(attributes)) {
|
if (!Array.isArray(attributes)) {
|
||||||
attributes = [];
|
attributes = [];
|
||||||
if (this.collection.isParent()) {
|
if (this.collection.isParent()) {
|
||||||
attributes.push(this.inheritFromSubQuery());
|
this.inheritFromSubQuery(attributes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import QueryInterface from './query-interface';
|
import QueryInterface from './query-interface';
|
||||||
import { Collection } from '../collection';
|
import { Collection } from '../collection';
|
||||||
import { Transactionable } from 'sequelize';
|
import { Transactionable } from 'sequelize';
|
||||||
|
import sqlParser from '../sql-parser';
|
||||||
|
|
||||||
export default class MysqlQueryInterface extends QueryInterface {
|
export default class MysqlQueryInterface extends QueryInterface {
|
||||||
constructor(db) {
|
constructor(db) {
|
||||||
@ -12,9 +13,57 @@ export default class MysqlQueryInterface extends QueryInterface {
|
|||||||
|
|
||||||
const tableName = collection.model.tableName;
|
const tableName = collection.model.tableName;
|
||||||
const databaseName = this.db.options.database;
|
const databaseName = this.db.options.database;
|
||||||
const sql = `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '${databaseName}' AND TABLE_NAME = '${tableName}'`;
|
const sql = `SELECT TABLE_NAME
|
||||||
|
FROM INFORMATION_SCHEMA.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = '${databaseName}'
|
||||||
|
AND TABLE_NAME = '${tableName}'`;
|
||||||
|
|
||||||
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
|
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
|
||||||
return results.length > 0;
|
return results.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listViews() {
|
||||||
|
const sql = `SELECT TABLE_NAME as name, VIEW_DEFINITION as definition
|
||||||
|
FROM information_schema.views
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
ORDER BY TABLE_NAME;`;
|
||||||
|
|
||||||
|
return await this.db.sequelize.query(sql, { type: 'SELECT' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async viewColumnUsage(options: { viewName: string; schema?: string }): Promise<
|
||||||
|
Array<{
|
||||||
|
column_name: string;
|
||||||
|
table_name: string;
|
||||||
|
table_schema?: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const viewDefinition = await this.db.sequelize.query(`SHOW CREATE VIEW ${options.viewName}`, { type: 'SELECT' });
|
||||||
|
const createView = viewDefinition[0]['Create View'];
|
||||||
|
const regex = /(?<=AS\s)([\s\S]*)/i;
|
||||||
|
const match = createView.match(regex);
|
||||||
|
const sql = match[0];
|
||||||
|
|
||||||
|
const { ast } = sqlParser.parse(sql);
|
||||||
|
|
||||||
|
const columns = ast.columns;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const column of columns) {
|
||||||
|
if (column.expr.type === 'column_ref') {
|
||||||
|
results.push({
|
||||||
|
column_name: column.expr.column,
|
||||||
|
table_name: column.expr.table,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
this.db.logger.warn(e);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,11 +12,35 @@ export default class PostgresQueryInterface extends QueryInterface {
|
|||||||
const tableName = collection.model.tableName;
|
const tableName = collection.model.tableName;
|
||||||
const schema = collection.collectionSchema() || 'public';
|
const schema = collection.collectionSchema() || 'public';
|
||||||
|
|
||||||
const sql = `SELECT EXISTS(SELECT 1 FROM information_schema.tables
|
const sql = `SELECT EXISTS(SELECT 1
|
||||||
WHERE table_schema = '${schema}'
|
FROM information_schema.tables
|
||||||
AND table_name = '${tableName}')`;
|
WHERE table_schema = '${schema}'
|
||||||
|
AND table_name = '${tableName}')`;
|
||||||
|
|
||||||
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
|
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
|
||||||
return results[0]['exists'];
|
return results[0]['exists'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listViews() {
|
||||||
|
const sql = `
|
||||||
|
SELECT viewname as name, definition, schemaname as schema
|
||||||
|
FROM pg_views
|
||||||
|
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
ORDER BY viewname;
|
||||||
|
`;
|
||||||
|
|
||||||
|
return await this.db.sequelize.query(sql, { type: 'SELECT' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async viewColumnUsage(options) {
|
||||||
|
const { viewName, schema = 'public' } = options;
|
||||||
|
const sql = `
|
||||||
|
SELECT *
|
||||||
|
FROM information_schema.view_column_usage
|
||||||
|
WHERE view_schema = '${schema}'
|
||||||
|
AND view_name = '${viewName}';
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (await this.db.sequelize.query(sql, { type: 'SELECT' })) as any;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,9 +4,40 @@ import { QueryInterface as SequelizeQueryInterface, Transactionable } from 'sequ
|
|||||||
|
|
||||||
export default abstract class QueryInterface {
|
export default abstract class QueryInterface {
|
||||||
sequelizeQueryInterface: SequelizeQueryInterface;
|
sequelizeQueryInterface: SequelizeQueryInterface;
|
||||||
|
|
||||||
protected constructor(public db: Database) {
|
protected constructor(public db: Database) {
|
||||||
this.sequelizeQueryInterface = db.sequelize.getQueryInterface();
|
this.sequelizeQueryInterface = db.sequelize.getQueryInterface();
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract collectionTableExists(collection: Collection, options?: Transactionable): Promise<boolean>;
|
abstract collectionTableExists(collection: Collection, options?: Transactionable): Promise<boolean>;
|
||||||
|
|
||||||
|
abstract listViews();
|
||||||
|
|
||||||
|
abstract viewColumnUsage(options: { viewName: string; schema?: string }): Promise<
|
||||||
|
Array<{
|
||||||
|
column_name: string;
|
||||||
|
table_name: string;
|
||||||
|
table_schema?: string;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
|
||||||
|
async dropAll(options) {
|
||||||
|
if (options.drop !== true) return;
|
||||||
|
|
||||||
|
const views = await this.listViews();
|
||||||
|
|
||||||
|
for (const view of views) {
|
||||||
|
let removeSql;
|
||||||
|
|
||||||
|
if (view.schema) {
|
||||||
|
removeSql = `DROP VIEW IF EXISTS "${view.schema}"."${view.name}"`;
|
||||||
|
} else {
|
||||||
|
removeSql = `DROP VIEW IF EXISTS ${view.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.sequelize.query(removeSql, { transaction: options.transaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.sequelize.getQueryInterface().dropAllTables(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import QueryInterface from './query-interface';
|
import QueryInterface from './query-interface';
|
||||||
import { Collection } from '../collection';
|
import { Collection } from '../collection';
|
||||||
|
import sqlParser from '../sql-parser';
|
||||||
|
|
||||||
export default class SqliteQueryInterface extends QueryInterface {
|
export default class SqliteQueryInterface extends QueryInterface {
|
||||||
constructor(db) {
|
constructor(db) {
|
||||||
@ -11,8 +12,65 @@ export default class SqliteQueryInterface extends QueryInterface {
|
|||||||
|
|
||||||
const tableName = collection.model.tableName;
|
const tableName = collection.model.tableName;
|
||||||
|
|
||||||
const sql = `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}';`;
|
const sql = `SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name = '${tableName}';`;
|
||||||
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
|
const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
|
||||||
return results.length > 0;
|
return results.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listViews() {
|
||||||
|
const sql = `
|
||||||
|
SELECT name , sql as definition
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'view'
|
||||||
|
ORDER BY name;
|
||||||
|
`;
|
||||||
|
|
||||||
|
return await this.db.sequelize.query(sql, {
|
||||||
|
type: 'SELECT',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async viewColumnUsage(options: { viewName: string; schema?: string }): Promise<
|
||||||
|
Array<{
|
||||||
|
column_name: string;
|
||||||
|
table_name: string;
|
||||||
|
table_schema?: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const viewDefinition = await this.db.sequelize.query(
|
||||||
|
`SELECT sql FROM sqlite_master WHERE name = '${options.viewName}' AND type = 'view'`,
|
||||||
|
{
|
||||||
|
type: 'SELECT',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const createView = viewDefinition[0]['sql'];
|
||||||
|
const regex = /(?<=AS\s)([\s\S]*)/i;
|
||||||
|
const match = createView.match(regex);
|
||||||
|
const sql = match[0];
|
||||||
|
|
||||||
|
const { ast } = sqlParser.parse(sql);
|
||||||
|
|
||||||
|
const columns = ast.columns;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const column of columns) {
|
||||||
|
if (column.expr.type === 'column_ref') {
|
||||||
|
results.push({
|
||||||
|
column_name: column.expr.column,
|
||||||
|
table_name: column.expr.table,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
this.db.logger.warn(e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ import {
|
|||||||
Op,
|
Op,
|
||||||
Transactionable,
|
Transactionable,
|
||||||
UpdateOptions as SequelizeUpdateOptions,
|
UpdateOptions as SequelizeUpdateOptions,
|
||||||
WhereOperators
|
WhereOperators,
|
||||||
} from 'sequelize';
|
} from 'sequelize';
|
||||||
import { Collection } from './collection';
|
import { Collection } from './collection';
|
||||||
import { Database } from './database';
|
import { Database } from './database';
|
||||||
@ -340,7 +340,9 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
|
|||||||
if (this.collection.isParent()) {
|
if (this.collection.isParent()) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const rowCollectionName = this.database.tableNameCollectionMap.get(
|
const rowCollectionName = this.database.tableNameCollectionMap.get(
|
||||||
options.raw ? row['__tableName'] : row.get('__tableName'),
|
options.raw
|
||||||
|
? `${row['__schemaName']}.${row['__tableName']}`
|
||||||
|
: `${row.get('__schemaName')}.${row.get('__tableName')}`,
|
||||||
).name;
|
).name;
|
||||||
|
|
||||||
options.raw
|
options.raw
|
||||||
|
10698
packages/core/database/src/sql-parser/index.js
Normal file
10698
packages/core/database/src/sql-parser/index.js
Normal file
File diff suppressed because it is too large
Load Diff
2
packages/core/database/src/sql-parser/readme.md
Normal file
2
packages/core/database/src/sql-parser/readme.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
use peggy to transform pegjs to sql parser
|
||||||
|
https://github.com/peggyjs/peggy
|
1297
packages/core/database/src/sql-parser/sql.pegjs
Normal file
1297
packages/core/database/src/sql-parser/sql.pegjs
Normal file
File diff suppressed because it is too large
Load Diff
20
packages/core/database/src/view-collection.ts
Normal file
20
packages/core/database/src/view-collection.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Collection, CollectionContext, CollectionOptions } from './collection';
|
||||||
|
|
||||||
|
export class ViewCollection extends Collection {
|
||||||
|
constructor(options: CollectionOptions, context: CollectionContext) {
|
||||||
|
options.autoGenId = false;
|
||||||
|
options.timestamps = false;
|
||||||
|
|
||||||
|
super(options, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sequelizeModelOptions(): any {
|
||||||
|
const modelOptions = super.sequelizeModelOptions();
|
||||||
|
modelOptions.tableName = this.options.viewName || this.options.name;
|
||||||
|
return modelOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
isView() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
56
packages/core/database/src/view/field-type-map.ts
Normal file
56
packages/core/database/src/view/field-type-map.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
const postgres = {
|
||||||
|
'character varying': 'string',
|
||||||
|
varchar: 'string',
|
||||||
|
text: 'text',
|
||||||
|
char: 'string',
|
||||||
|
|
||||||
|
smallint: 'integer',
|
||||||
|
integer: 'integer',
|
||||||
|
bigint: 'bigInt',
|
||||||
|
decimal: 'float',
|
||||||
|
numeric: 'float',
|
||||||
|
'double precision': 'float',
|
||||||
|
|
||||||
|
'timestamp without time zone': 'date',
|
||||||
|
'timestamp with time zone': 'date',
|
||||||
|
date: 'date',
|
||||||
|
boolean: 'boolean',
|
||||||
|
|
||||||
|
json: ['json', 'array'],
|
||||||
|
jsonb: ['jsonb', 'array'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mysql = {
|
||||||
|
varchar: 'string',
|
||||||
|
text: 'text',
|
||||||
|
int: 'integer',
|
||||||
|
integer: 'integer',
|
||||||
|
bigint: 'bigInt',
|
||||||
|
float: 'float',
|
||||||
|
double: 'float',
|
||||||
|
boolean: 'boolean',
|
||||||
|
|
||||||
|
tinyint: 'integer',
|
||||||
|
datetime: 'date',
|
||||||
|
timestamp: 'date',
|
||||||
|
json: ['json', 'array'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sqlite = {
|
||||||
|
text: 'text',
|
||||||
|
varchar: 'string',
|
||||||
|
|
||||||
|
integer: 'integer',
|
||||||
|
real: 'real',
|
||||||
|
|
||||||
|
datetime: 'date',
|
||||||
|
date: 'date',
|
||||||
|
time: 'time',
|
||||||
|
|
||||||
|
boolean: 'boolean',
|
||||||
|
|
||||||
|
numeric: 'decimal',
|
||||||
|
json: ['json', 'array'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { postgres, mysql, sqlite };
|
106
packages/core/database/src/view/view-inference.ts
Normal file
106
packages/core/database/src/view/view-inference.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import Database from '../database';
|
||||||
|
import FieldTypeMap from './field-type-map';
|
||||||
|
import { isArray } from 'mathjs';
|
||||||
|
|
||||||
|
type InferredField = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InferredFieldResult = {
|
||||||
|
[key: string]: InferredField;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ViewFieldInference {
|
||||||
|
static async inferFields(options: {
|
||||||
|
db: Database;
|
||||||
|
viewName: string;
|
||||||
|
viewSchema?: string;
|
||||||
|
}): Promise<InferredFieldResult> {
|
||||||
|
const { db } = options;
|
||||||
|
if (!db.inDialect('postgres')) {
|
||||||
|
options.viewSchema = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = await db.sequelize.getQueryInterface().describeTable(options.viewName, options.viewSchema);
|
||||||
|
|
||||||
|
const columnUsage = await db.queryInterface.viewColumnUsage({
|
||||||
|
viewName: options.viewName,
|
||||||
|
schema: options.viewSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(columns).map(([name, column]) => {
|
||||||
|
const usage = columnUsage.find((item) => item.column_name === name);
|
||||||
|
|
||||||
|
if (usage) {
|
||||||
|
const collectionField = (() => {
|
||||||
|
const tableName = `${usage.table_schema ? `${usage.table_schema}.` : ''}${usage.table_name}`;
|
||||||
|
const collection = db.tableNameCollectionMap.get(tableName);
|
||||||
|
if (!collection) return false;
|
||||||
|
|
||||||
|
const fieldValue = Object.values(collection.model.rawAttributes).find(
|
||||||
|
(field) => field.field === usage.column_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fieldValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const fieldName = fieldValue?.fieldName;
|
||||||
|
|
||||||
|
return collection.getField(fieldName);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (collectionField && collectionField.options.interface) {
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
type: collectionField.type,
|
||||||
|
source: `${collectionField.collection.name}.${collectionField.name}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
...this.inferToFieldType({ db, name, type: column.type }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inferToFieldType(options: { db: Database; name: string; type: string }) {
|
||||||
|
const { db } = options;
|
||||||
|
const dialect = db.sequelize.getDialect();
|
||||||
|
const fieldTypeMap = FieldTypeMap[dialect];
|
||||||
|
|
||||||
|
if (!options.type) {
|
||||||
|
return {
|
||||||
|
possibleTypes: Object.keys(fieldTypeMap),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryType = options.type.toLowerCase().replace(/\(\d+\)/, '');
|
||||||
|
const mappedType = fieldTypeMap[queryType];
|
||||||
|
|
||||||
|
if (isArray(mappedType)) {
|
||||||
|
return {
|
||||||
|
type: mappedType[0],
|
||||||
|
possibleTypes: mappedType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: mappedType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
import { ACL } from '@nocobase/acl';
|
import {ACL} from '@nocobase/acl';
|
||||||
import { Database, HasManyRepository } from '@nocobase/database';
|
import {Database, HasManyRepository} from '@nocobase/database';
|
||||||
import UsersPlugin from '@nocobase/plugin-users';
|
import UsersPlugin from '@nocobase/plugin-users';
|
||||||
import { MockServer } from '@nocobase/test';
|
import {MockServer} from '@nocobase/test';
|
||||||
import { prepareApp } from './prepare';
|
import {prepareApp} from './prepare';
|
||||||
|
|
||||||
describe('association test', () => {
|
describe('association test', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
@ -36,6 +36,7 @@ describe('association test', () => {
|
|||||||
context: {},
|
context: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
await db.getRepository('collections').create({
|
await db.getRepository('collections').create({
|
||||||
values: {
|
values: {
|
||||||
name: 'comments',
|
name: 'comments',
|
||||||
|
@ -60,6 +60,7 @@ describe('field indexes', () => {
|
|||||||
|
|
||||||
it('field value cannot be duplicated with unique index', async () => {
|
it('field value cannot be duplicated with unique index', async () => {
|
||||||
const tableName = 'test1';
|
const tableName = 'test1';
|
||||||
|
|
||||||
// create a field with unique constraint
|
// create a field with unique constraint
|
||||||
const field = await agent.resource('collections.fields', tableName).create({
|
const field = await agent.resource('collections.fields', tableName).create({
|
||||||
values: {
|
values: {
|
||||||
|
@ -0,0 +1,381 @@
|
|||||||
|
import { MockServer } from '@nocobase/test';
|
||||||
|
import { createApp } from '../index';
|
||||||
|
import { uid } from '@nocobase/utils';
|
||||||
|
|
||||||
|
describe('view collection', () => {
|
||||||
|
let app: MockServer;
|
||||||
|
let agent;
|
||||||
|
let testViewName;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await createApp({
|
||||||
|
database: {
|
||||||
|
tablePrefix: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
agent = app.agent();
|
||||||
|
testViewName = `view_${uid(6)}`;
|
||||||
|
const dropSQL = `DROP VIEW IF EXISTS ${testViewName}`;
|
||||||
|
await app.db.sequelize.query(dropSQL);
|
||||||
|
const viewSQL = (() => {
|
||||||
|
if (app.db.inDialect('sqlite')) {
|
||||||
|
return `CREATE VIEW ${testViewName} AS WITH RECURSIVE numbers(n) AS (
|
||||||
|
SELECT CAST(1 AS INTEGER)
|
||||||
|
UNION ALL
|
||||||
|
SELECT CAST(1 + n AS INTEGER) FROM numbers WHERE n < 20
|
||||||
|
)
|
||||||
|
SELECT * FROM numbers;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `CREATE VIEW ${testViewName} AS WITH RECURSIVE numbers(n) AS (
|
||||||
|
SELECT 1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n + 1 FROM numbers WHERE n < 20
|
||||||
|
)
|
||||||
|
SELECT * FROM numbers;
|
||||||
|
`;
|
||||||
|
})();
|
||||||
|
await app.db.sequelize.query(viewSQL);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list views', async () => {
|
||||||
|
const response = await agent.resource('dbViews').list();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.data.find((item) => item.name === testViewName)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should query views data', async () => {
|
||||||
|
const response = await agent.resource('dbViews').query({
|
||||||
|
filterByTk: testViewName,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.data.length).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list views fields', async () => {
|
||||||
|
const response = await agent.resource('dbViews').get({
|
||||||
|
filterByTk: testViewName,
|
||||||
|
schema: 'public',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = response.body.data;
|
||||||
|
if (app.db.options.dialect === 'mysql') {
|
||||||
|
expect(data.fields.n.type).toBe('bigInt');
|
||||||
|
} else if (app.db.options.dialect == 'postgres') {
|
||||||
|
expect(data.fields.n.type).toBe('integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
// cannot get field type in sqlite
|
||||||
|
if (app.db.options.dialect === 'sqlite') {
|
||||||
|
expect(data.fields.n.possibleTypes).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return possible types for json fields', async () => {
|
||||||
|
const jsonViewName = 'json_view';
|
||||||
|
const dropSql = `DROP VIEW IF EXISTS ${jsonViewName}`;
|
||||||
|
await app.db.sequelize.query(dropSql);
|
||||||
|
|
||||||
|
const jsonViewSQL = (() => {
|
||||||
|
if (app.db.inDialect('postgres')) {
|
||||||
|
return `CREATE VIEW ${jsonViewName} AS SELECT '{"a": 1}'::json as json_field`;
|
||||||
|
}
|
||||||
|
return `CREATE VIEW ${jsonViewName} AS SELECT JSON_OBJECT('key1', 1, 'key2', 'abc') as json_field`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await app.db.sequelize.query(jsonViewSQL);
|
||||||
|
|
||||||
|
const response = await agent.resource('dbViews').get({
|
||||||
|
filterByTk: jsonViewName,
|
||||||
|
schema: app.db.inDialect('postgres') ? 'public' : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = response.body.data;
|
||||||
|
if (!app.db.inDialect('sqlite')) {
|
||||||
|
expect(data.fields.json_field.type).toBe('json');
|
||||||
|
}
|
||||||
|
expect(data.fields.json_field.possibleTypes).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list collections fields with source interface', async () => {
|
||||||
|
await app.db.getCollection('collections').repository.create({
|
||||||
|
values: {
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
interface: 'text',
|
||||||
|
uiSchema: 'name-uiSchema',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'age',
|
||||||
|
type: 'integer',
|
||||||
|
interface: 'number',
|
||||||
|
uiSchema: 'age-uiSchema',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
const UserCollection = app.db.getCollection('users');
|
||||||
|
|
||||||
|
const viewName = `t_${uid(6)}`;
|
||||||
|
const dropSQL = `DROP VIEW IF EXISTS ${viewName}`;
|
||||||
|
await app.db.sequelize.query(dropSQL);
|
||||||
|
const viewSQL = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
|
||||||
|
await app.db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
// create view collection
|
||||||
|
const viewCollection = await app.db.getCollection('collections').repository.create({
|
||||||
|
values: {
|
||||||
|
name: viewName,
|
||||||
|
view: true,
|
||||||
|
schema: app.db.inDialect('postgres') ? 'public' : undefined,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
source: 'users.name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'age',
|
||||||
|
type: 'integer',
|
||||||
|
source: 'users.age',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await agent.resource('collections').list({
|
||||||
|
appends: ['fields'],
|
||||||
|
paginate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listResult = response.body.data.find((item) => item.name === viewName);
|
||||||
|
|
||||||
|
const fields = listResult.fields;
|
||||||
|
|
||||||
|
const nameField = fields.find((item) => item.name === 'name');
|
||||||
|
expect(nameField.interface).toBe('text');
|
||||||
|
expect(nameField.uiSchema).toBe('name-uiSchema');
|
||||||
|
|
||||||
|
const viewFieldsResponse = await agent.resource('collections.fields', viewName).list({
|
||||||
|
filter: {
|
||||||
|
$or: {
|
||||||
|
'interface.$not': null,
|
||||||
|
'options.source.$notEmpty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(viewFieldsResponse.status).toEqual(200);
|
||||||
|
const viewFieldsData = viewFieldsResponse.body.data;
|
||||||
|
expect(viewFieldsData.length).toEqual(2);
|
||||||
|
|
||||||
|
expect(viewFieldsData.find((item) => item.name === 'name').interface).toEqual('text');
|
||||||
|
|
||||||
|
const fieldDetailResponse = await agent.resource('collections.fields', viewName).get({
|
||||||
|
filterByTk: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fieldDetailData = fieldDetailResponse.body.data;
|
||||||
|
expect(fieldDetailData.interface).toEqual('text');
|
||||||
|
|
||||||
|
UserCollection.addField('email', { type: 'string' });
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
|
||||||
|
// update view in database
|
||||||
|
await app.db.sequelize.query(dropSQL);
|
||||||
|
const viewSQL2 = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
|
||||||
|
await app.db.sequelize.query(viewSQL2);
|
||||||
|
|
||||||
|
const viewDetailResponse = await agent.resource('dbViews').get({
|
||||||
|
filterByTk: viewName,
|
||||||
|
schema: 'public',
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewDetail = viewDetailResponse.body.data;
|
||||||
|
const viewFields = viewDetail.fields;
|
||||||
|
|
||||||
|
const updateFieldsResponse = await agent.resource('collections').setFields({
|
||||||
|
filterByTk: viewName,
|
||||||
|
values: {
|
||||||
|
fields: Object.values(viewFields),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateFieldsResponse.status).toEqual(200);
|
||||||
|
|
||||||
|
const viewCollectionWithEmail = app.db.getCollection(viewName);
|
||||||
|
expect(viewCollectionWithEmail.getField('email')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should access view collection resource', async () => {
|
||||||
|
const UserCollection = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
|
||||||
|
await UserCollection.repository.create({
|
||||||
|
values: {
|
||||||
|
name: 'John',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// create view
|
||||||
|
const viewName = `t_${uid(6)}`;
|
||||||
|
const dropSQL = `DROP VIEW IF EXISTS ${viewName}`;
|
||||||
|
await app.db.sequelize.query(dropSQL);
|
||||||
|
const viewSQL = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
|
||||||
|
await app.db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
// create view collection
|
||||||
|
await app.db.getCollection('collections').repository.create({
|
||||||
|
values: {
|
||||||
|
name: viewName,
|
||||||
|
view: true,
|
||||||
|
schema: app.db.inDialect('postgres') ? 'public' : undefined,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'integer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewCollection = app.db.getCollection(viewName);
|
||||||
|
|
||||||
|
// access view collection list
|
||||||
|
const listResponse = await agent.resource(viewCollection.name).list({});
|
||||||
|
expect(listResponse.status).toEqual(200);
|
||||||
|
|
||||||
|
const item = listResponse.body.data[0];
|
||||||
|
|
||||||
|
// access detail
|
||||||
|
const detailResponse = await agent.resource(viewCollection.name).get({
|
||||||
|
filterByTk: item['id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detailResponse.status).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get view in difference schema', async () => {
|
||||||
|
if (!app.db.inDialect('postgres')) return;
|
||||||
|
|
||||||
|
const schemaName = `t_${uid(6)}`;
|
||||||
|
const testSchemaSql = `CREATE SCHEMA IF NOT EXISTS ${schemaName};`;
|
||||||
|
await app.db.sequelize.query(testSchemaSql);
|
||||||
|
|
||||||
|
const viewName = `v_${uid(6)}`;
|
||||||
|
|
||||||
|
const viewSQL = `CREATE OR REPLACE VIEW ${schemaName}.${viewName} AS SELECT 1+1 as result`;
|
||||||
|
await app.db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
const response = await agent.resource('dbViews').query({
|
||||||
|
filterByTk: viewName,
|
||||||
|
schema: schemaName,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should edit uiSchema in view collection field', async () => {
|
||||||
|
await app.db.getCollection('collections').repository.create({
|
||||||
|
values: {
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
uiSchema: {
|
||||||
|
title: 'hello',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
|
||||||
|
const UserCollection = app.db.getCollection('users');
|
||||||
|
|
||||||
|
// create view
|
||||||
|
const viewName = `t_${uid(6)}`;
|
||||||
|
const dropSQL = `DROP VIEW IF EXISTS ${viewName}`;
|
||||||
|
await app.db.sequelize.query(dropSQL);
|
||||||
|
const viewSQL = `CREATE VIEW ${viewName} AS SELECT * FROM ${UserCollection.quotedTableName()}`;
|
||||||
|
await app.db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
// create view collection
|
||||||
|
await app.db.getCollection('collections').repository.create({
|
||||||
|
values: {
|
||||||
|
name: viewName,
|
||||||
|
view: true,
|
||||||
|
schema: 'public',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'integer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
source: 'users.name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.getCollection('fields').repository.update({
|
||||||
|
filter: {
|
||||||
|
name: 'name',
|
||||||
|
collectionName: viewName,
|
||||||
|
},
|
||||||
|
|
||||||
|
values: {
|
||||||
|
uiSchema: {
|
||||||
|
title: 'bars',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewCollection = app.db.getCollection(viewName);
|
||||||
|
|
||||||
|
expect(viewCollection.getField('name').options.uiSchema.title).toEqual('bars');
|
||||||
|
|
||||||
|
const viewFieldsResponse = await agent.resource('collections.fields', viewName).list({});
|
||||||
|
const nameField = viewFieldsResponse.body.data.find((item) => item.name === 'name');
|
||||||
|
expect(nameField.uiSchema.title).toEqual('bars');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,122 @@
|
|||||||
|
import Database, { Repository, ViewCollection } from '@nocobase/database';
|
||||||
|
import Application from '@nocobase/server';
|
||||||
|
import { createApp } from '../index';
|
||||||
|
|
||||||
|
describe('view collection', function () {
|
||||||
|
let db: Database;
|
||||||
|
let app: Application;
|
||||||
|
|
||||||
|
let collectionRepository: Repository;
|
||||||
|
|
||||||
|
let fieldsRepository: Repository;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await createApp({
|
||||||
|
database: {
|
||||||
|
tablePrefix: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
db = app.db;
|
||||||
|
|
||||||
|
collectionRepository = db.getCollection('collections').repository;
|
||||||
|
fieldsRepository = db.getCollection('fields').repository;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support view with dot field', async () => {
|
||||||
|
const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
|
||||||
|
await db.sequelize.query(dropViewSQL);
|
||||||
|
const viewSQL = `CREATE VIEW test_view AS select 1+1 as "dot.results"`;
|
||||||
|
await db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
await collectionRepository.create({
|
||||||
|
values: {
|
||||||
|
name: 'view_collection',
|
||||||
|
viewName: 'test_view',
|
||||||
|
fields: [{ type: 'string', name: 'dot_result', field: 'dot.results' }],
|
||||||
|
schema: db.inDialect('postgres') ? 'public' : undefined,
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewCollection = db.getCollection('view_collection');
|
||||||
|
|
||||||
|
const results = await viewCollection.repository.find();
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create view collection by view name', async () => {
|
||||||
|
const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
|
||||||
|
await db.sequelize.query(dropViewSQL);
|
||||||
|
const viewSQL = `CREATE VIEW test_view AS select 1+1 as result`;
|
||||||
|
await db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
await collectionRepository.create({
|
||||||
|
values: {
|
||||||
|
name: 'view_collection',
|
||||||
|
viewName: 'test_view',
|
||||||
|
fields: [{ type: 'string', name: 'result' }],
|
||||||
|
schema: db.inDialect('postgres') ? 'public' : undefined,
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewCollection = db.getCollection('view_collection');
|
||||||
|
expect(viewCollection).toBeInstanceOf(ViewCollection);
|
||||||
|
|
||||||
|
const results = await viewCollection.repository.find();
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should destroy collection view', async () => {
|
||||||
|
const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
|
||||||
|
await db.sequelize.query(dropViewSQL);
|
||||||
|
const viewSQL = `CREATE VIEW test_view AS select 1+1 as result`;
|
||||||
|
await db.sequelize.query(viewSQL);
|
||||||
|
|
||||||
|
await collectionRepository.create({
|
||||||
|
values: {
|
||||||
|
name: 'view_collection',
|
||||||
|
viewName: 'test_view',
|
||||||
|
fields: [{ type: 'string', name: 'result' }],
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await fieldsRepository.findOne({
|
||||||
|
filter: {
|
||||||
|
collectionName: 'view_collection',
|
||||||
|
name: 'result',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
await fieldsRepository.destroy({
|
||||||
|
filter: {
|
||||||
|
collectionName: 'view_collection',
|
||||||
|
name: 'result',
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await fieldsRepository.findOne({
|
||||||
|
filter: {
|
||||||
|
collectionName: 'view_collection',
|
||||||
|
name: 'result',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeFalsy();
|
||||||
|
|
||||||
|
await collectionRepository.destroy({
|
||||||
|
filterByTk: 'view_collection',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.getCollection('view_collection')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,16 @@
|
|||||||
|
import { Database } from '@nocobase/database';
|
||||||
|
|
||||||
|
export function beforeCreateForViewCollection(db: Database) {
|
||||||
|
return async (model, { transaction, context }) => {
|
||||||
|
if (model.get('viewSQL')) {
|
||||||
|
const name = model.get('name');
|
||||||
|
const sql = model.get('viewSQL');
|
||||||
|
|
||||||
|
await db.sequelize.query(`CREATE OR REPLACE VIEW "${name}" AS ${sql}`, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
model.set('viewName', name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
import { Database } from '@nocobase/database';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async ['collections:setFields'](ctx, next) {
|
||||||
|
const { filterByTk, values } = ctx.action.params;
|
||||||
|
|
||||||
|
// WARN: 删掉 key 才能保存
|
||||||
|
const fields = values.fields?.map(f => {
|
||||||
|
delete f.key;
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = ctx.app.db as Database;
|
||||||
|
|
||||||
|
const collection = await db.getRepository('collections').findOne({
|
||||||
|
filter: {
|
||||||
|
name: filterByTk,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.getRepository('collections').update({
|
||||||
|
filterByTk,
|
||||||
|
values: {
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await collection.loadFields();
|
||||||
|
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
};
|
55
packages/plugins/collection-manager/src/resourcers/views.ts
Normal file
55
packages/plugins/collection-manager/src/resourcers/views.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Database, ViewFieldInference } from '@nocobase/database';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'dbViews',
|
||||||
|
actions: {
|
||||||
|
async get(ctx, next) {
|
||||||
|
const { filterByTk, schema } = ctx.action.params;
|
||||||
|
const db = ctx.app.db as Database;
|
||||||
|
|
||||||
|
const fields = await ViewFieldInference.inferFields({
|
||||||
|
db,
|
||||||
|
viewName: filterByTk,
|
||||||
|
viewSchema: schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
fields,
|
||||||
|
sources: [
|
||||||
|
...new Set(
|
||||||
|
Object.values(fields)
|
||||||
|
.map((field) => field.source)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((source) => source.split('.')[0]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async list(ctx, next) {
|
||||||
|
const db = ctx.app.db as Database;
|
||||||
|
const dbViews = await db.queryInterface.listViews();
|
||||||
|
ctx.body = dbViews.map((dbView) => {
|
||||||
|
return {
|
||||||
|
...dbView,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
|
||||||
|
async query(ctx, next) {
|
||||||
|
const { filterByTk, schema = 'public', page = 1, pageSize = 10 } = ctx.action.params;
|
||||||
|
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const limit = 1 * pageSize;
|
||||||
|
|
||||||
|
const sql = `SELECT *
|
||||||
|
FROM ${ctx.app.db.utils.quoteTable(ctx.app.db.utils.addSchema(filterByTk, schema))} LIMIT ${limit} OFFSET ${offset}`;
|
||||||
|
|
||||||
|
ctx.body = await ctx.app.db.sequelize.query(sql, { type: 'SELECT' });
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user