Merge branch 'main' into next

# Conflicts:
#	packages/core/client/src/schema-component/antd/page/Page.tsx
This commit is contained in:
chenos 2024-07-09 09:17:52 +08:00
commit 4cd2454882
20 changed files with 404 additions and 11 deletions

View File

@ -7,6 +7,7 @@ Thank you!
### This is a ...
- [ ] New feature
- [ ] Improvement
- [ ] Bug fix
- [ ] Others

View File

@ -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"

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@ -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;

View File

@ -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 });

View File

@ -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 () {

View File

@ -1,4 +1,5 @@
{
"Export warning": "每次最多导出 {{limit}} 行数据,超出的将被忽略。",
"Start export": "开始导出"
"Start export": "开始导出",
"another export action is running, please try again later.": "另一导出任务正在运行,请稍后重试。"
}

View File

@ -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',

View File

@ -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();

View File

@ -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;
});
}

View File

@ -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.": "另一导入任务正在运行,请稍后重试。"
}

View File

@ -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();

View File

@ -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}}\""
}

View File

@ -1,3 +1,4 @@
{
"field-name-exists": "字段标识 \"{{name}}\" 已存在"
"field-name-exists": "字段标识 \"{{name}}\" 已存在",
"field-is-depended-on-by-other": "无法删除 \"{{fieldCollectionName}}\" 中的 \"{{fieldName}}\" 字段,它被 \"{{dependedFieldCollectionName}}\" 中的 \"{{dependedFieldName}}\" 字段用作 \"{{dependedFieldAs}}\""
}

View File

@ -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: {},
});
});
});

View File

@ -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';
}
}

View File

@ -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,
});
}
};
}

View File

@ -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);

View File

@ -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) => {

View File

@ -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);
});
});