Merge branch 'main' into T-4256

This commit is contained in:
katherinehhh 2024-05-20 11:49:29 +08:00
commit cd056e700a
17 changed files with 415 additions and 86 deletions

View File

@ -7,22 +7,37 @@ assignees: ''
---
## Describe the bug
<!-- Note: Please do not clear the contents of the issue template. Items marked with * are required. Issues not filled out according to the template will be closed. -->
<!-- 注意:请不要将 issue 模板内容清空,带 * 的项目为必填项没有按照模板填写的issue将被关闭。-->
## * Describe the bug
<!-- A clear and concise description of what the bug is. -->
## Version
## * Environment
<!-- NocoBase version that you are using. -->
<!-- Please view it by clicking on the ? icon in the upper right corner of the NocoBase navigation bar. -->
- NocoBase version:
## How To Reproduce
<!-- [e.g. PostgreSQL 12, MySQL 8.x, SQLite] -->
- Database type and version:
Steps to reproduce the behavior:
<!-- [e.g. MacOS, Windows] -->
- OS:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
<!-- Docker, Create-nocobase-app, Git source code -->
- Deployment Methods:
<!-- If using Docker for deployment, please provide. [e.g. nocobase/nocobase:latest] -->
- Docker image version:
<!-- If using Create-nocobase-app or Git source code for deployment, please provide. -->
- NodeJS version:
## * How To Reproduce
<!-- Please describe the reproduction process in as much detail as possible. -->
## Expected behavior
@ -32,18 +47,6 @@ Steps to reproduce the behavior:
<!-- If applicable, add screenshots to help explain your problem. -->
## Desktop (please complete the following information)
## Logs
- OS: [e.g. iOS]
- Browser [e.g. chrome v102, safari]
## Smartphone (please complete the following information)
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
## Additional context
<!-- Add any other context about the problem here. -->
<!-- If it's an API error, please provide the relevant server logs. -->

View File

@ -50,4 +50,49 @@ describe('update or create', () => {
expect(post).not.toBeNull();
expect(post['title']).toEqual('t1');
});
test('update or create with empty values', async () => {
await Post.repository.create({ values: { title: 't1' } });
const response = await app
.agent()
.resource('posts')
.updateOrCreate({
values: {},
filterKeys: ['title'],
});
expect(response.statusCode).toEqual(200);
const post = await Post.repository.findOne({
filter: {
'title.$empty': true,
},
});
expect(post).not.toBeNull();
await app
.agent()
.resource('posts')
.updateOrCreate({
values: {},
filterKeys: ['title'],
});
expect(
await Post.repository.count({
filter: {
'title.$empty': true,
},
}),
).toEqual(1);
expect(
await Post.repository.count({
filter: {
title: 't1',
},
}),
).toEqual(1);
});
});

View File

@ -40,6 +40,7 @@ export default defineConfig({
window['__webpack_public_path__'] = '{{env.APP_PUBLIC_PATH}}';
window['__nocobase_public_path__'] = '{{env.APP_PUBLIC_PATH}}';
window['__nocobase_api_base_url__'] = '{{env.API_BASE_URL}}';
window['__nocobase_api_client_storage_prefix__'] = '{{env.API_CLIENT_STORAGE_PREFIX}}';
window['__nocobase_ws_url__'] = '{{env.WS_URL}}';
window['__nocobase_ws_path__'] = '{{env.WS_PATH}}';
`,

View File

@ -1,3 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Application } from '@nocobase/client';
import { NocoBaseClientPresetPlugin } from '@nocobase/preset-nocobase/client';
import devDynamicImport from '../.plugins/index';
@ -5,6 +14,10 @@ import devDynamicImport from '../.plugins/index';
export const app = new Application({
apiClient: {
// @ts-ignore
storagePrefix:
// @ts-ignore
window['__nocobase_api_client_storage_prefix__'] || process.env.API_CLIENT_STORAGE_PREFIX || 'NOCOBASE_',
// @ts-ignore
baseURL: window['__nocobase_api_base_url__'] || process.env.API_BASE_URL || '/api/',
},
// @ts-ignore

View File

@ -286,6 +286,7 @@ function buildIndexHtml(force = false) {
const data = fs.readFileSync(tpl, 'utf-8');
const replacedData = data
.replace(/\{\{env.APP_PUBLIC_PATH\}\}/g, process.env.APP_PUBLIC_PATH)
.replace(/\{\{env.API_CLIENT_STORAGE_PREFIX\}\}/g, process.env.API_CLIENT_STORAGE_PREFIX)
.replace(/\{\{env.API_BASE_URL\}\}/g, process.env.API_BASE_URL || process.env.API_BASE_PATH)
.replace(/\{\{env.WS_URL\}\}/g, process.env.WEBSOCKET_URL || '')
.replace(/\{\{env.WS_PATH\}\}/g, process.env.WS_PATH)
@ -301,6 +302,7 @@ exports.initEnv = function initEnv() {
APP_KEY: 'test-jwt-secret',
APP_PORT: 13000,
API_BASE_PATH: '/api/',
API_CLIENT_STORAGE_PREFIX: 'NOCOBASE_',
DB_DIALECT: 'sqlite',
DB_STORAGE: 'storage/db/nocobase.sqlite',
DB_TIMEZONE: '+00:00',

View File

@ -134,6 +134,9 @@ export class Application {
this.pluginSettingsManager = new PluginSettingsManager(options.pluginSettings, this);
this.addRoutes();
this.name = this.options.name || getSubAppName(options.publicPath) || 'main';
this.i18n.on('languageChanged', (lng) => {
this.apiClient.auth.locale = lng;
});
}
private initRequireJs() {

View File

@ -30,7 +30,7 @@ i18n
// .use(Backend)
.use(initReactI18next)
.init({
lng: localStorage.getItem('NOCOBASE_LOCALE') || 'en-US',
lng: 'en-US',
// debug: true,
defaultNS: 'client',
// fallbackNS: 'client',
@ -47,7 +47,3 @@ i18n
keySeparator: false,
nsSeparator: false,
});
i18n.on('languageChanged', (lng) => {
localStorage.setItem('NOCOBASE_LOCALE', lng);
});

View File

@ -204,6 +204,37 @@ describe('array field operator', function () {
expect(filter3[0].get('name')).toEqual(t2.get('name'));
});
test('$anyOf with association with same column name', async () => {
const Tag = db.collection({
name: 'tags',
fields: [
{ type: 'array', name: 'type' },
{ type: 'string', name: 'name' },
],
});
const Post = db.collection({
name: 'posts',
tableName: 'posts_table',
fields: [
{ type: 'array', name: 'type' },
{
type: 'hasMany',
name: 'tags',
},
],
});
await db.sync({ force: true });
await Post.repository.find({
filter: {
'type.$anyOf': ['aa'],
'tags.name': 't1',
},
});
});
// fix https://nocobase.height.app/T-2803
test('$anyOf with string', async () => {
const filter3 = await Test.repository.find({

View File

@ -191,8 +191,11 @@ describe('create', () => {
},
},
});
expect(u1.name).toEqual('u1');
const group = await u1.get('group');
expect(group.name).toEqual('g1');
const u2 = await User.repository.firstOrCreate({

View File

@ -33,15 +33,18 @@ const getFieldName = (ctx) => {
const model = getModelFromAssociationPath();
let columnPrefix = model.name;
if (model.rawAttributes[fieldName]) {
columnName = model.rawAttributes[fieldName].field || fieldName;
}
if (associationPath.length > 0) {
const association = associationPath.join('->');
columnName = `${association}.${columnName}`;
columnPrefix = association;
}
columnName = `${columnPrefix}.${columnName}`;
return columnName;
};

View File

@ -242,11 +242,8 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
this.model = collection.model;
}
public static valuesToFilter(values: Values, filterKeys: Array<string>) {
const filterAnd = [];
const flattedValues = flatten(values);
const keyWithOutArrayIndex = (key) => {
public static valuesToFilter(values: Values = {}, filterKeys: Array<string>) {
const removeArrayIndexInKey = (key) => {
const chunks = key.split('.');
return chunks
.filter((chunk) => {
@ -255,29 +252,36 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
.join('.');
};
const filterAnd = [];
const flattedValues = flatten(values);
const flattedValuesObject = {};
for (const key in flattedValues) {
const keyWithoutArrayIndex = removeArrayIndexInKey(key);
if (flattedValuesObject[keyWithoutArrayIndex]) {
if (!Array.isArray(flattedValuesObject[keyWithoutArrayIndex])) {
flattedValuesObject[keyWithoutArrayIndex] = [flattedValuesObject[keyWithoutArrayIndex]];
}
flattedValuesObject[keyWithoutArrayIndex].push(flattedValues[key]);
} else {
flattedValuesObject[keyWithoutArrayIndex] = [flattedValues[key]];
}
}
for (const filterKey of filterKeys) {
let filterValue;
for (const flattedKey of Object.keys(flattedValues)) {
const flattedKeyWithoutIndex = keyWithOutArrayIndex(flattedKey);
if (flattedKeyWithoutIndex === filterKey) {
if (filterValue) {
if (Array.isArray(filterValue)) {
filterValue.push(flattedValues[flattedKey]);
} else {
filterValue = [filterValue, flattedValues[flattedKey]];
}
} else {
filterValue = flattedValues[flattedKey];
}
}
}
const filterValue = flattedValuesObject[filterKey]
? flattedValuesObject[filterKey]
: lodash.get(values, filterKey);
if (filterValue) {
filterAnd.push({
[filterKey]: filterValue,
});
} else {
filterAnd.push({
[filterKey]: null,
});
}
}

View File

@ -8,7 +8,7 @@ const path = require('path');
console.log('VERSION: ', packageJson.version);
function getUmiConfig() {
const { APP_PORT, API_BASE_URL, APP_PUBLIC_PATH } = process.env;
const { APP_PORT, API_BASE_URL, API_CLIENT_STORAGE_PREFIX, APP_PUBLIC_PATH } = process.env;
const API_BASE_PATH = process.env.API_BASE_PATH || '/api/';
const PROXY_TARGET_URL = process.env.PROXY_TARGET_URL || `http://127.0.0.1:${APP_PORT}`;
const LOCAL_STORAGE_BASE_URL = 'storage/uploads/';
@ -40,6 +40,7 @@ function getUmiConfig() {
'process.env.APP_PUBLIC_PATH': process.env.APP_PUBLIC_PATH,
'process.env.WS_PATH': process.env.WS_PATH,
'process.env.API_BASE_URL': API_BASE_URL || API_BASE_PATH,
'process.env.API_CLIENT_STORAGE_PREFIX': API_CLIENT_STORAGE_PREFIX,
'process.env.APP_ENV': process.env.APP_ENV,
'process.env.VERSION': packageJson.version,
'process.env.WEBSOCKET_URL': process.env.WEBSOCKET_URL,

View File

@ -9,7 +9,6 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse } from 'axios';
import qs from 'qs';
import getSubAppName from './getSubAppName';
export interface ActionParams {
filterByTk?: any;
@ -32,14 +31,30 @@ export type IResource = {
export class Auth {
protected api: APIClient;
protected KEYS = {
locale: 'NOCOBASE_LOCALE',
role: 'NOCOBASE_ROLE',
token: 'NOCOBASE_TOKEN',
authenticator: 'NOCOBASE_AUTH',
theme: 'NOCOBASE_THEME',
get storagePrefix() {
return this.api.storagePrefix;
}
get KEYS() {
const defaults = {
locale: this.storagePrefix + 'LOCALE',
role: this.storagePrefix + 'ROLE',
token: this.storagePrefix + 'TOKEN',
authenticator: this.storagePrefix + 'AUTH',
theme: this.storagePrefix + 'THEME',
};
if (this.api['app']) {
const appName = this.api['app']?.getName?.();
if (appName) {
defaults['role'] = `${appName.toUpperCase()}_` + defaults['role'];
defaults['locale'] = `${appName.toUpperCase()}_` + defaults['locale'];
}
}
return defaults;
}
protected options = {
locale: null,
role: null,
@ -49,22 +64,9 @@ export class Auth {
constructor(api: APIClient) {
this.api = api;
this.initKeys();
this.api.axios.interceptors.request.use(this.middleware.bind(this));
}
initKeys() {
if (typeof window === 'undefined') {
return;
}
const appName = getSubAppName(this.api['app'] ? this.api['app'].getPublicPath() : '/');
if (!appName) {
return;
}
this.KEYS['role'] = `${appName.toUpperCase()}_` + this.KEYS['role'];
this.KEYS['locale'] = `${appName.toUpperCase()}_` + this.KEYS['locale'];
}
get locale() {
return this.getLocale();
}
@ -266,6 +268,7 @@ export class MemoryStorage extends Storage {
interface ExtendedOptions {
authClass?: any;
storageClass?: any;
storagePrefix?: string;
}
export type APIClientOptions = AxiosInstance | (AxiosRequestConfig & ExtendedOptions);
@ -274,6 +277,7 @@ export class APIClient {
axios: AxiosInstance;
auth: Auth;
storage: Storage;
storagePrefix = 'NOCOBASE_';
getHeaders() {
const headers = {};
@ -296,7 +300,8 @@ export class APIClient {
if (typeof instance === 'function') {
this.axios = instance;
} else {
const { authClass, storageClass, ...others } = instance || {};
const { authClass, storageClass, storagePrefix = 'NOCOBASE_', ...others } = instance || {};
this.storagePrefix = storagePrefix;
this.axios = axios.create(others);
this.initStorage(storageClass);
if (authClass) {

View File

@ -0,0 +1,180 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { MockServer } from '@nocobase/test';
import { createApp } from '../index';
import { Database, MigrationContext } from '@nocobase/database';
import Migrator from '../../migrations/20240517101001-fix-target-option-in-tree-collection';
describe('fix target option in tree collection', () => {
let app: MockServer;
let db: Database;
beforeEach(async () => {
app = await createApp({});
db = app.db;
});
afterEach(async () => {
await app.destroy();
});
it('should remove target options', async () => {
await app.db.getRepository('collections').create({
values: {
logging: true,
autoGenId: true,
createdAt: true,
createdBy: true,
updatedAt: true,
updatedBy: true,
fields: [
{
interface: 'integer',
name: 'parentId',
type: 'bigInt',
isForeignKey: true,
uiSchema: {
type: 'number',
title: '{{t("Parent ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
target: 'treeTests',
},
{
interface: 'm2o',
type: 'belongsTo',
name: 'parent',
foreignKey: 'parentId',
treeParent: true,
onDelete: 'CASCADE',
uiSchema: {
title: '{{t("Parent")}}',
'x-component': 'AssociationField',
'x-component-props': { multiple: false, fieldNames: { label: 'id', value: 'id' } },
},
target: 'treeTests',
},
{
interface: 'o2m',
type: 'hasMany',
name: 'children',
foreignKey: 'parentId',
treeChildren: true,
onDelete: 'CASCADE',
uiSchema: {
title: '{{t("Children")}}',
'x-component': 'AssociationField',
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
},
target: 'treeTests',
},
{
name: 'id',
type: 'bigInt',
autoIncrement: true,
primaryKey: true,
allowNull: false,
uiSchema: {
type: 'number',
title: '{{t("ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
interface: 'integer',
target: 'treeTests',
},
{
name: 'createdAt',
interface: 'createdAt',
type: 'date',
field: 'createdAt',
uiSchema: {
type: 'datetime',
title: '{{t("Created at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
},
{
name: 'createdBy',
interface: 'createdBy',
type: 'belongsTo',
target: 'users',
foreignKey: 'createdById',
uiSchema: {
type: 'object',
title: '{{t("Created by")}}',
'x-component': 'AssociationField',
'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } },
'x-read-pretty': true,
},
},
{
type: 'date',
field: 'updatedAt',
name: 'updatedAt',
interface: 'updatedAt',
uiSchema: {
type: 'string',
title: '{{t("Last updated at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
},
{
type: 'belongsTo',
target: 'users',
foreignKey: 'updatedById',
name: 'updatedBy',
interface: 'updatedBy',
uiSchema: {
type: 'object',
title: '{{t("Last updated by")}}',
'x-component': 'AssociationField',
'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } },
'x-read-pretty': true,
},
},
],
name: 'treeTests',
template: 'tree',
view: false,
tree: 'adjacencyList',
title: 'treeTests',
},
});
const idFieldBeforeMigrate = await app.db.getRepository('fields').findOne({
filter: {
collectionName: 'treeTests',
name: 'id',
},
});
expect(idFieldBeforeMigrate.get('target')).toBe('treeTests');
const migration = new Migrator({ db } as MigrationContext);
migration.context.app = app;
await migration.up();
const idField = await app.db.getRepository('fields').findOne({
filter: {
collectionName: 'treeTests',
name: 'id',
},
});
expect(idField.get('target')).toBeUndefined();
});
});

View File

@ -0,0 +1,35 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Migration } from '@nocobase/server';
export default class extends Migration {
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
appVersion = '<1.0.0-alpha.15';
async up() {
const treeCollections = await this.app.db.getRepository('collections').find({
appends: ['fields'],
filter: {
'options.tree': 'adjacencyList',
},
});
for (const treeCollection of treeCollections) {
const fields = treeCollection.get('fields');
for (const field of fields) {
if (!['belongsTo', 'hasMany', 'belongsToMany', 'hasOne'].includes(field.get('type')) && field.get('target')) {
field.set('target', undefined);
await field.save();
}
}
}
}
}

View File

@ -219,13 +219,14 @@ const CurrentFields = (props) => {
const InheritFields = (props) => {
const compile = useCompile();
const { getInterface } = useCollectionManager_deprecated();
const { resource, targetKey } = props.collectionResource || {};
const { targetKey } = props.collectionResource || {};
const parentRecord = useRecord();
const [loadingRecord, setLoadingRecord] = React.useState(null);
const { t } = useTranslation();
const { refreshCM, isTitleField } = useCollectionManager_deprecated();
const { [targetKey]: filterByTk, titleField, name } = parentRecord;
const ctx = useContext(CollectionListContext);
const api = useAPIClient();
const columns: TableColumnProps<any>[] = [
{
@ -246,20 +247,19 @@ const InheritFields = (props) => {
dataIndex: 'titleField',
title: t('Title field'),
render(_, record) {
const handleChange = (checked) => {
const handleChange = async (checked) => {
setLoadingRecord(record);
resource
.update({ filterByTk, values: { titleField: checked ? record.name : 'id' } })
.then(async () => {
await api.request({
url: `collections:update?filterByTk=${filterByTk}`,
method: 'post',
data: { titleField: checked ? record.name : 'id' },
});
message.success(t('Saved successfully'));
await props.refreshAsync();
setLoadingRecord(null);
refreshCM();
ctx?.refresh?.();
})
.catch((err) => {
setLoadingRecord(null);
console.error(err);
});
};
return isTitleField(record) ? (

View File

@ -90,6 +90,9 @@ export const processData = (selectedFields: FieldOption[], data: any[], scope: a
if (!options || !Array.isArray(options)) {
return value;
}
if (Array.isArray(value)) {
return value.map((v) => parseEnum(field, v));
}
const option = options.find((option) => option.value === value);
return Schema.compile(option?.label || value, scope);
};
@ -104,6 +107,7 @@ export const processData = (selectedFields: FieldOption[], data: any[], scope: a
switch (field.interface) {
case 'select':
case 'radioGroup':
case 'multipleSelect':
processed[key] = parseEnum(field, value);
break;
default: