mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 05:25:52 +00:00
Merge branch 'main' into next
# Conflicts: # packages/core/client/src/schema-component/antd/page/Page.tsx
This commit is contained in:
commit
4cd2454882
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@ -7,6 +7,7 @@ Thank you!
|
||||
|
||||
### This is a ...
|
||||
- [ ] New feature
|
||||
- [ ] Improvement
|
||||
- [ ] Bug fix
|
||||
- [ ] Others
|
||||
|
||||
|
13
.github/workflows/manual-build-pro-image.yml
vendored
13
.github/workflows/manual-build-pro-image.yml
vendored
@ -7,6 +7,10 @@ concurrency:
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_branch:
|
||||
description: 'Please enter a base branch for main repo'
|
||||
required: true
|
||||
default: 'main'
|
||||
pr_number:
|
||||
description: 'Please enter a pull request number'
|
||||
required: true
|
||||
@ -24,14 +28,21 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.inputs.base_branch }}
|
||||
ssh-key: ${{ secrets.SUBMODULE_SSH_KEY }}
|
||||
submodules: true
|
||||
- name: Set PR branch
|
||||
id: set_pro_pr_branch
|
||||
if: inputs.pr_number != 'main'
|
||||
run: echo "pr_branch=refs/pull/${{ github.event.inputs.pr_number }}/head" >> $GITHUB_OUTPUT
|
||||
- name: Echo PR branch
|
||||
run: echo "${{ steps.set_pro_pr_branch.outputs.pr_branch }}"
|
||||
- name: Checkout pro-plugins
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: nocobase/pro-plugins
|
||||
path: packages/pro-plugins
|
||||
ref: ${{ inputs.pr_number != 'main' && 'refs/pull/' + inputs.pr_number + '/head' || 'main' }}
|
||||
ref: ${{ steps.set_pro_pr_branch.outputs.pr_branch || 'main' }}
|
||||
ssh-key: ${{ secrets.SUBMODULE_SSH_KEY }}
|
||||
- name: rm .git
|
||||
run: rm -rf packages/pro-plugins/.git && git config --global user.email "you@example.com" && git config --global user.name "Your Name" && git add -A && git commit -m "tmp commit"
|
||||
|
2
packages/core/app/client/public/robots.txt
Normal file
2
packages/core/app/client/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
@ -55,6 +55,7 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Last-Modified $date_gmt;
|
||||
add_header Cache-Control 'no-store, no-cache';
|
||||
add_header X-Robots-Tag "noindex, nofollow";
|
||||
if_modified_since off;
|
||||
expires off;
|
||||
etag off;
|
||||
|
@ -76,6 +76,12 @@ describe('moment2str', () => {
|
||||
expect(str).toBe(dayjs('2023-06-21 10:10:00').toISOString());
|
||||
});
|
||||
|
||||
test('gmt not configured', () => {
|
||||
const d = dayjs('2024-06-30');
|
||||
const str = moment2str(d);
|
||||
expect(str).toBe(dayjs('2024-06-30 00:00:00').toISOString());
|
||||
});
|
||||
|
||||
test('with time', () => {
|
||||
const m = dayjs('2023-06-21 10:10:00');
|
||||
const str = moment2str(m, { showTime: true });
|
||||
|
@ -71,7 +71,7 @@ export const moment2str = (value?: Dayjs | null, options: Moment2strOptions = {}
|
||||
if (typeof gmt === 'boolean') {
|
||||
return gmt ? toGmtByPicker(value, picker) : toLocalByPicker(value, picker);
|
||||
}
|
||||
return toGmtByPicker(value, picker);
|
||||
return toLocalByPicker(value, picker);
|
||||
};
|
||||
|
||||
export const mapDatePicker = function () {
|
||||
|
@ -1,4 +1,5 @@
|
||||
{
|
||||
"Export warning": "每次最多导出 {{limit}} 行数据,超出的将被忽略。",
|
||||
"Start export": "开始导出"
|
||||
"Start export": "开始导出",
|
||||
"another export action is running, please try again later.": "另一导出任务正在运行,请稍后重试。"
|
||||
}
|
||||
|
@ -539,6 +539,71 @@ describe('export to xlsx', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should export with custom title', async () => {
|
||||
const User = app.db.collection({
|
||||
name: 'users',
|
||||
fields: [
|
||||
{ type: 'string', name: 'name', title: '姓名' },
|
||||
{ type: 'integer', name: 'age', title: '年龄' },
|
||||
],
|
||||
});
|
||||
|
||||
await app.db.sync();
|
||||
const values = Array.from({ length: 20 }).map((_, index) => {
|
||||
return {
|
||||
name: `user${index}`,
|
||||
age: index % 100,
|
||||
};
|
||||
});
|
||||
|
||||
await User.model.bulkCreate(values);
|
||||
|
||||
const exporter = new XlsxExporter({
|
||||
collectionManager: app.mainDataSource.collectionManager,
|
||||
collection: User,
|
||||
chunkSize: 10,
|
||||
columns: [
|
||||
{ dataIndex: ['name'], title: '123', defaultTitle: 'Name' },
|
||||
{ dataIndex: ['age'], title: '345', defaultTitle: 'Age' },
|
||||
],
|
||||
findOptions: {
|
||||
filter: {
|
||||
age: {
|
||||
$gt: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wb = await exporter.run();
|
||||
|
||||
const xlsxFilePath = path.resolve(__dirname, `t_${uid()}.xlsx`);
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
XLSX.writeFileAsync(
|
||||
xlsxFilePath,
|
||||
wb,
|
||||
{
|
||||
type: 'array',
|
||||
},
|
||||
() => {
|
||||
resolve(123);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// read xlsx file
|
||||
const workbook = XLSX.readFile(xlsxFilePath);
|
||||
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const sheetData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
|
||||
|
||||
const header = sheetData[0];
|
||||
expect(header).toEqual(['123', '345']);
|
||||
} finally {
|
||||
fs.unlinkSync(xlsxFilePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should export with empty title', async () => {
|
||||
const User = app.db.collection({
|
||||
name: 'users',
|
||||
|
@ -54,7 +54,11 @@ async function exportXlsxAction(ctx: Context, next: Next) {
|
||||
|
||||
export async function exportXlsx(ctx: Context, next: Next) {
|
||||
if (mutex.isLocked()) {
|
||||
throw new Error(`another export action is running, please try again later.`);
|
||||
throw new Error(
|
||||
ctx.t(`another export action is running, please try again later.`, {
|
||||
ns: 'action-export',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const release = await mutex.acquire();
|
||||
|
@ -21,6 +21,7 @@ import { deepGet } from './utils/deep-get';
|
||||
|
||||
type ExportColumn = {
|
||||
dataIndex: Array<string>;
|
||||
title?: string;
|
||||
defaultTitle: string;
|
||||
};
|
||||
|
||||
@ -146,6 +147,10 @@ class XlsxExporter {
|
||||
private renderHeaders() {
|
||||
return this.options.columns.map((col) => {
|
||||
const field = this.findFieldByDataIndex(col.dataIndex);
|
||||
if (col.title) {
|
||||
return col.title;
|
||||
}
|
||||
|
||||
return field?.options.title || col.defaultTitle;
|
||||
});
|
||||
}
|
||||
|
@ -25,5 +25,6 @@
|
||||
"Incorrect date format": "日期格式不正确",
|
||||
"Incorrect email format": "邮箱格式不正确",
|
||||
"Illegal percentage format": "百分比格式有误",
|
||||
"Imported template does not match, please download again.": "导入模板不匹配,请检查导入文件标题行或重新下载导入模板"
|
||||
"Imported template does not match, please download again.": "导入模板不匹配,请检查导入文件标题行或重新下载导入模板",
|
||||
"another import action is running, please try again later.": "另一导入任务正在运行,请稍后重试。"
|
||||
}
|
||||
|
@ -59,7 +59,11 @@ async function importXlsxAction(ctx: Context, next: Next) {
|
||||
|
||||
export async function importXlsx(ctx: Context, next: Next) {
|
||||
if (mutex.isLocked()) {
|
||||
throw new Error(`another import action is running, please try again later.`);
|
||||
throw new Error(
|
||||
ctx.t(`another import action is running, please try again later.`, {
|
||||
ns: 'action-import',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const release = await mutex.acquire();
|
||||
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"field-name-exists": "Field name \"{{name}}\" already exists in collection \"{{collectionName}}\""
|
||||
"field-name-exists": "Field name \"{{name}}\" already exists in collection \"{{collectionName}}\"",
|
||||
"field-is-depended-on-by-other": "Can not delete field \"{{fieldName}}\" in \"{{fieldCollectionName}}\", it is used by field \"{{dependedFieldName}}\" in \"{{dependedFieldCollectionName}}\" as \"{{dependedFieldAs}}\""
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"field-name-exists": "字段标识 \"{{name}}\" 已存在"
|
||||
"field-name-exists": "字段标识 \"{{name}}\" 已存在",
|
||||
"field-is-depended-on-by-other": "无法删除 \"{{fieldCollectionName}}\" 中的 \"{{fieldName}}\" 字段,它被 \"{{dependedFieldCollectionName}}\" 中的 \"{{dependedFieldName}}\" 字段用作 \"{{dependedFieldAs}}\""
|
||||
}
|
||||
|
@ -0,0 +1,191 @@
|
||||
import Database from '@nocobase/database';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { createApp } from '..';
|
||||
|
||||
describe('destory key that used by association field', () => {
|
||||
let db: Database;
|
||||
let app: MockServer;
|
||||
beforeEach(async () => {
|
||||
app = await createApp();
|
||||
db = app.db;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should destroy field cascade', async () => {
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'posts',
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
primaryKey: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'content',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'comments',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'comment',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
// add has many field
|
||||
await db.getRepository('fields').create({
|
||||
values: {
|
||||
name: 'post',
|
||||
collectionName: 'comments',
|
||||
type: 'belongsTo',
|
||||
target: 'posts',
|
||||
foreignKey: 'postTitle',
|
||||
targetKey: 'title',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
const CommentCollection = db.getCollection('comments');
|
||||
|
||||
expect(CommentCollection.getField('post')).toBeTruthy();
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'posts2',
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
primaryKey: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'comments2',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'comment',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await db.getRepository('fields').create({
|
||||
values: {
|
||||
name: 'post',
|
||||
collectionName: 'comments2',
|
||||
type: 'belongsTo',
|
||||
target: 'posts2',
|
||||
foreignKey: 'postTitle',
|
||||
targetKey: 'title',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
// destroy collection cascade
|
||||
await db.getRepository('collections').destroy({
|
||||
filter: {
|
||||
name: 'posts',
|
||||
},
|
||||
cascade: true,
|
||||
});
|
||||
|
||||
expect(CommentCollection.getField('post')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when destory a source key of hasMany field', async () => {
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'posts',
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
primaryKey: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'content',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'comments',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'comment',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
// add has many field
|
||||
await db.getRepository('fields').create({
|
||||
values: {
|
||||
name: 'comments',
|
||||
collectionName: 'posts',
|
||||
type: 'hasMany',
|
||||
target: 'comments',
|
||||
foreignKey: 'postTitle',
|
||||
sourceKey: 'title',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
// it should throw error when destroy title field
|
||||
let error;
|
||||
try {
|
||||
await db.getRepository('fields').destroy({
|
||||
filter: {
|
||||
name: 'title',
|
||||
collectionName: 'posts',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeTruthy();
|
||||
expect(error.message).toBe(
|
||||
`Can't delete field title of posts, it is used by field comments in collection posts as sourceKey`,
|
||||
);
|
||||
|
||||
// it should destroy posts collection
|
||||
await db.getRepository('collections').destroy({
|
||||
filter: {
|
||||
name: 'posts',
|
||||
},
|
||||
cascade: true,
|
||||
context: {},
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,16 @@
|
||||
type FieldIsDependedOnByOtherErrorOptions = {
|
||||
fieldName: string;
|
||||
fieldCollectionName: string;
|
||||
dependedFieldName: string;
|
||||
dependedFieldCollectionName: string;
|
||||
dependedFieldAs: string;
|
||||
};
|
||||
|
||||
export class FieldIsDependedOnByOtherError extends Error {
|
||||
constructor(public options: FieldIsDependedOnByOtherErrorOptions) {
|
||||
super(
|
||||
`Can't delete field ${options.fieldName} of ${options.fieldCollectionName}, it is used by field ${options.dependedFieldName} in collection ${options.dependedFieldCollectionName} as ${options.dependedFieldAs}`,
|
||||
);
|
||||
this.name = 'FieldIsDependedOnByOtherError';
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
import { Database } from '@nocobase/database';
|
||||
import { FieldIsDependedOnByOtherError } from '../errors/field-is-depended-on-by-other';
|
||||
|
||||
export function beforeDestoryField(db: Database) {
|
||||
return async (model, opts) => {
|
||||
const { transaction } = opts;
|
||||
const { name, type, collectionName } = model.get();
|
||||
|
||||
if (['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relatedFields = await db.getRepository('fields').find({
|
||||
filter: {
|
||||
$or: [
|
||||
{
|
||||
['options.sourceKey']: name,
|
||||
collectionName,
|
||||
},
|
||||
{
|
||||
['options.targetKey']: name,
|
||||
['options.target']: collectionName,
|
||||
},
|
||||
],
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
for (const field of relatedFields) {
|
||||
const keys = [
|
||||
{
|
||||
name: 'sourceKey',
|
||||
condition: (associationField) =>
|
||||
associationField.options['sourceKey'] === name && associationField.collectionName === collectionName,
|
||||
},
|
||||
{
|
||||
name: 'targetKey',
|
||||
condition: (associationField) =>
|
||||
associationField.options['targetKey'] === name && associationField.options['target'] === collectionName,
|
||||
},
|
||||
];
|
||||
|
||||
const usedAs = keys.find((key) => key.condition(field))['name'];
|
||||
|
||||
throw new FieldIsDependedOnByOtherError({
|
||||
fieldName: name,
|
||||
fieldCollectionName: collectionName,
|
||||
dependedFieldName: field.get('name'),
|
||||
dependedFieldCollectionName: field.get('collectionName'),
|
||||
dependedFieldAs: usedAs,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
@ -158,6 +158,10 @@ export class CollectionModel extends MagicAttributeModel {
|
||||
} else if (field.get('through') && field.get('through') === name) {
|
||||
await field.destroy({ transaction });
|
||||
}
|
||||
|
||||
if (field.get('collectionName') === name) {
|
||||
await field.destroy({ transaction });
|
||||
}
|
||||
}
|
||||
|
||||
await collection.removeFromDb(options);
|
||||
|
@ -28,6 +28,8 @@ import { CollectionModel, FieldModel } from './models';
|
||||
import collectionActions from './resourcers/collections';
|
||||
import viewResourcer from './resourcers/views';
|
||||
import { FieldNameExistsError } from './errors/field-name-exists-error';
|
||||
import { beforeDestoryField } from './hooks/beforeDestoryField';
|
||||
import { FieldIsDependedOnByOtherError } from './errors/field-is-depended-on-by-other';
|
||||
|
||||
export class PluginDataSourceMainServer extends Plugin {
|
||||
public schema: string;
|
||||
@ -85,7 +87,7 @@ export class PluginDataSourceMainServer extends Plugin {
|
||||
removeOptions['transaction'] = options.transaction;
|
||||
}
|
||||
|
||||
const cascade = lodash.get(options, 'context.action.params.cascade', false);
|
||||
const cascade = options.cascade || lodash.get(options, 'context.action.params.cascade', false);
|
||||
|
||||
if (cascade === true || cascade === 'true') {
|
||||
removeOptions['cascade'] = true;
|
||||
@ -243,6 +245,7 @@ export class PluginDataSourceMainServer extends Plugin {
|
||||
});
|
||||
|
||||
// before field remove
|
||||
this.app.db.on('fields.beforeDestroy', beforeDestoryField(this.app.db));
|
||||
this.app.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.app.db));
|
||||
|
||||
const mutex = new Mutex();
|
||||
@ -333,6 +336,27 @@ export class PluginDataSourceMainServer extends Plugin {
|
||||
},
|
||||
);
|
||||
|
||||
errorHandlerPlugin.errorHandler.register(
|
||||
(err) => err instanceof FieldIsDependedOnByOtherError,
|
||||
(err, ctx) => {
|
||||
ctx.status = 400;
|
||||
ctx.body = {
|
||||
errors: [
|
||||
{
|
||||
message: ctx.i18n.t('field-is-depended-on-by-other', {
|
||||
fieldName: err.options.fieldName,
|
||||
fieldCollectionName: err.options.fieldCollectionName,
|
||||
dependedFieldName: err.options.dependedFieldName,
|
||||
dependedFieldCollectionName: err.options.dependedFieldCollectionName,
|
||||
dependedFieldAs: err.options.dependedFieldAs,
|
||||
ns: 'data-source-main',
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
errorHandlerPlugin.errorHandler.register(
|
||||
(err) => err instanceof FieldNameExistsError,
|
||||
(err, ctx) => {
|
||||
|
@ -10,7 +10,6 @@
|
||||
import { onFormValuesChange } from '@formily/core';
|
||||
import { useField, useFieldSchema, useFormEffects } from '@formily/react';
|
||||
import { toJS } from '@formily/reactive';
|
||||
import type { CollectionOptions } from '@nocobase/client';
|
||||
import {
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
@ -21,6 +20,7 @@ import {
|
||||
useFormBlockContext,
|
||||
ActionContext,
|
||||
} from '@nocobase/client';
|
||||
import { debounce } from 'lodash';
|
||||
import { Evaluator, evaluators } from '@nocobase/evaluators/client';
|
||||
import { Registry, toFixedByStep } from '@nocobase/utils/client';
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
@ -80,6 +80,7 @@ export function Result(props) {
|
||||
}, [value]);
|
||||
|
||||
useFormEffects(() => {
|
||||
const delayedOnChange = debounce(props.onChange, 300);
|
||||
onFormValuesChange((form) => {
|
||||
if (
|
||||
(fieldSchema.name as string).indexOf('.') >= 0 ||
|
||||
@ -101,7 +102,7 @@ export function Result(props) {
|
||||
setEditingValue(v);
|
||||
}
|
||||
setEditingValue(v);
|
||||
props.onChange(v);
|
||||
delayedOnChange(v);
|
||||
ctx?.setFormValueChanged?.(false);
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user