Merge branch 'main' into T-4256

This commit is contained in:
katherinehhh 2024-05-17 15:14:29 +08:00
commit 4aea20dafb
12 changed files with 170 additions and 34 deletions

View File

@ -42,7 +42,7 @@ jobs:
with: with:
node-version: 18 node-version: 18
cache: 'yarn' cache: 'yarn'
- run: yarn --frozen-lockfile - run: yarn
- run: yarn build - run: yarn build
env: env:
__E2E__: true # e2e will be reusing this workflow, so we need to set this flag to true __E2E__: true # e2e will be reusing this workflow, so we need to set this flag to true
@ -104,7 +104,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- run: yarn --frozen-lockfile - run: yarn
- name: Download build artifact - name: Download build artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@ -184,7 +184,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- run: yarn --frozen-lockfile - run: yarn
- name: Download build artifact - name: Download build artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@ -264,7 +264,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- run: yarn --frozen-lockfile - run: yarn
- name: Download build artifact - name: Download build artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@ -310,7 +310,7 @@ jobs:
with: with:
node-version: 18 node-version: 18
cache: 'yarn' cache: 'yarn'
- run: yarn --frozen-lockfile - run: yarn
- name: Download e2e report artifact - name: Download e2e report artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4

View File

@ -211,6 +211,7 @@ export const AddCollectionAction = (props) => {
record, record,
showReverseFieldConfig: true, showReverseFieldConfig: true,
presetFieldsDisabled: currentTemplate?.presetFieldsDisabled, presetFieldsDisabled: currentTemplate?.presetFieldsDisabled,
presetFieldsDisabledIncludes: currentTemplate?.presetFieldsDisabledIncludes,
...scope, ...scope,
}} }}
/> />

View File

@ -204,7 +204,7 @@ export const PresetFields = observer(
selectedRowKeys, selectedRowKeys,
getCheckboxProps: (record) => ({ getCheckboxProps: (record) => ({
name: record.name, name: record.name,
disabled: props?.disabled, disabled: props?.disabled || props?.presetFieldsDisabledIncludes?.includes?.(record.name),
}), }),
onChange: (_, selectedRows) => { onChange: (_, selectedRows) => {
const fields = getDefaultCollectionFields(selectedRows, form.values); const fields = getDefaultCollectionFields(selectedRows, form.values);

View File

@ -75,6 +75,7 @@ export const defaultConfigurableProperties = {
'x-component': PresetFields, 'x-component': PresetFields,
'x-component-props': { 'x-component-props': {
disabled: '{{ presetFieldsDisabled }}', disabled: '{{ presetFieldsDisabled }}',
presetFieldsDisabledIncludes: '{{presetFieldsDisabledIncludes}}',
}, },
}, },
}; };

View File

@ -72,6 +72,7 @@ export class TreeCollectionTemplate extends CollectionTemplate {
}, },
], ],
}; };
presetFieldsDisabledIncludes = ['id'];
events = { events = {
beforeSubmit(values) { beforeSubmit(values) {
if (Array.isArray(values?.fields)) { if (Array.isArray(values?.fields)) {

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { Database } from '@nocobase/database'; import { Database, Repository } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test'; import { MockServer, createMockServer } from '@nocobase/test';
import compose from 'koa-compose'; import compose from 'koa-compose';
import { parseBuilder, parseFieldAndAssociations, queryData } from '../actions/query'; import { parseBuilder, parseFieldAndAssociations, queryData } from '../actions/query';
@ -15,6 +15,7 @@ import { parseBuilder, parseFieldAndAssociations, queryData } from '../actions/q
describe('api', () => { describe('api', () => {
let app: MockServer; let app: MockServer;
let db: Database; let db: Database;
let repo: Repository;
beforeAll(async () => { beforeAll(async () => {
app = await createMockServer({ app = await createMockServer({
@ -26,6 +27,10 @@ describe('api', () => {
db.collection({ db.collection({
name: 'chart_test', name: 'chart_test',
fields: [ fields: [
{
type: 'bigInt',
name: 'id',
},
{ {
type: 'double', type: 'double',
name: 'price', name: 'price',
@ -45,11 +50,11 @@ describe('api', () => {
], ],
}); });
await db.sync(); await db.sync();
const repo = db.getRepository('chart_test'); repo = db.getRepository('chart_test');
await repo.create({ await repo.create({
values: [ values: [
{ price: 1, count: 1, title: 'title1', createdAt: '2023-02-02' }, { id: 1, price: 1, count: 1, title: 'title1', createdAt: '2023-02-02' },
{ price: 2, count: 2, title: 'title2', createdAt: '2023-01-01' }, { id: 2, price: 2, count: 2, title: 'title2', createdAt: '2023-01-01' },
], ],
}); });
}); });
@ -124,4 +129,63 @@ describe('api', () => {
expect(ctx.action.params.values.data).toBeDefined(); expect(ctx.action.params.values.data).toBeDefined();
expect(ctx.action.params.values.data).toMatchObject([{ createdAt: '2023-01' }, { createdAt: '2023-02' }]); expect(ctx.action.params.values.data).toMatchObject([{ createdAt: '2023-01' }, { createdAt: '2023-02' }]);
}); });
test('datetime format with timezone', async () => {
const dialect = db.sequelize.getDialect();
if (dialect === 'sqlite') {
await repo.create({
values: {
id: 3,
createdAt: '2024-05-14 19:32:30.175 +00:00',
},
});
} else if (dialect === 'postgres') {
await repo.create({
values: {
id: 3,
createdAt: '2024-05-14 19:32:30.175+00',
},
});
} else if (dialect === 'mysql' || dialect === 'mariadb') {
await repo.create({
values: {
id: 3,
createdAt: '2024-05-14T19:32:30Z',
},
});
} else {
expect(true).toBe(true);
return;
}
const ctx = {
app,
db,
timezone: '+08:25',
action: {
params: {
values: {
collection: 'chart_test',
measures: [
{
field: ['id'],
aggregation: 'count',
},
],
dimensions: [
{
field: ['createdAt'],
format: 'YYYY-MM-DD',
},
],
filter: {
id: 3,
},
},
},
},
} as any;
await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {});
expect(ctx.action.params.values.data).toBeDefined();
expect(ctx.action.params.values.data).toMatchObject([{ createdAt: '2024-05-15' }]);
});
}); });

View File

@ -21,8 +21,8 @@ describe('formatter', () => {
}), }),
col: (field: string) => field, col: (field: string) => field,
getDialect: () => 'sqlite', getDialect: () => 'sqlite',
}; } as any;
const result = formatter(sequelize, 'datetime', field, format); const result = formatter(sequelize, 'datetime', field, format) as any;
expect(result.format).toEqual('%Y-%m-%d %H:%M:%S'); expect(result.format).toEqual('%Y-%m-%d %H:%M:%S');
}); });
@ -35,8 +35,8 @@ describe('formatter', () => {
}), }),
col: (field: string) => field, col: (field: string) => field,
getDialect: () => 'mysql', getDialect: () => 'mysql',
}; } as any;
const result = formatter(sequelize, 'datetime', field, format); const result = formatter(sequelize, 'datetime', field, format) as any;
expect(result.format).toEqual('%Y-%m-%d %H:%i:%S'); expect(result.format).toEqual('%Y-%m-%d %H:%i:%S');
}); });
@ -49,8 +49,8 @@ describe('formatter', () => {
}), }),
col: (field: string) => field, col: (field: string) => field,
getDialect: () => 'postgres', getDialect: () => 'postgres',
}; } as any;
const result = formatter(sequelize, 'datetime', field, format); const result = formatter(sequelize, 'datetime', field, format) as any;
expect(result.format).toEqual('YYYY-MM-DD HH24:MI:SS'); expect(result.format).toEqual('YYYY-MM-DD HH24:MI:SS');
}); });
}); });

View File

@ -18,26 +18,22 @@ import {
parseVariables, parseVariables,
postProcess, postProcess,
} from '../actions/query'; } from '../actions/query';
import { Database } from '@nocobase/database';
const formatter = await import('../actions/formatter'); const formatter = await import('../actions/formatter');
describe('query', () => { describe('query', () => {
describe('parseBuilder', () => { describe('parseBuilder', () => {
const sequelize = {
fn: vi.fn().mockImplementation((fn: string, field: string) => [fn, field]),
col: vi.fn().mockImplementation((field: string) => field),
getDialect() {
return false;
},
};
let ctx: any; let ctx: any;
let app: MockServer; let app: MockServer;
let db: Database;
beforeAll(async () => { beforeAll(async () => {
app = await createMockServer({ app = await createMockServer({
plugins: ['data-source-manager', 'users', 'acl'], plugins: ['data-source-manager', 'users', 'acl'],
}); });
app.db.options.underscored = true; db = app.db;
app.db.collection({ db.options.underscored = true;
db.collection({
name: 'orders', name: 'orders',
fields: [ fields: [
{ {
@ -63,9 +59,8 @@ describe('query', () => {
}); });
ctx = { ctx = {
app, app,
db: app.db, db,
}; };
ctx.db.sequelize = sequelize;
}); });
it('should check permissions', async () => { it('should check permissions', async () => {
@ -141,6 +136,7 @@ describe('query', () => {
], ],
}); });
}); });
it('should parse measures', async () => { it('should parse measures', async () => {
const measures1 = [ const measures1 = [
{ {
@ -159,7 +155,9 @@ describe('query', () => {
}, },
}; };
await compose([parseFieldAndAssociations, parseBuilder])(context, async () => {}); await compose([parseFieldAndAssociations, parseBuilder])(context, async () => {});
expect(context.action.params.values.queryParams.attributes).toEqual([['orders.price', 'price']]); expect(context.action.params.values.queryParams.attributes).toEqual([
[db.sequelize.col('orders.price'), 'price'],
]);
const measures2 = [ const measures2 = [
{ {
field: ['price'], field: ['price'],
@ -179,8 +177,11 @@ describe('query', () => {
}, },
}; };
await compose([parseFieldAndAssociations, parseBuilder])(context2, async () => {}); await compose([parseFieldAndAssociations, parseBuilder])(context2, async () => {});
expect(context2.action.params.values.queryParams.attributes).toEqual([[['sum', 'orders.price'], 'price-alias']]); expect(context2.action.params.values.queryParams.attributes).toEqual([
[db.sequelize.fn('sum', db.sequelize.col('orders.price')), 'price-alias'],
]);
}); });
it('should parse dimensions', async () => { it('should parse dimensions', async () => {
vi.spyOn(formatter, 'formatter').mockReturnValue('formatted-field'); vi.spyOn(formatter, 'formatter').mockReturnValue('formatted-field');
const dimensions = [ const dimensions = [
@ -225,6 +226,7 @@ describe('query', () => {
await compose([parseFieldAndAssociations, parseBuilder])(context2, async () => {}); await compose([parseFieldAndAssociations, parseBuilder])(context2, async () => {});
expect(context2.action.params.values.queryParams.group).toEqual(['formatted-field']); expect(context2.action.params.values.queryParams.group).toEqual(['formatted-field']);
}); });
it('should parse filter', async () => { it('should parse filter', async () => {
const filter = { const filter = {
createdAt: { createdAt: {

View File

@ -6,8 +6,25 @@
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { Sequelize } from 'sequelize';
export const dateFormatFn = (sequelize: any, dialect: string, field: string, format: string) => { const getOffsetMinutesFromTimezone = (timezone: string) => {
const sign = timezone.charAt(0);
timezone = timezone.slice(1);
const [hours, minutes] = timezone.split(':');
const hoursNum = Number(hours);
const minutesNum = Number(minutes);
const offset = hoursNum * 60 + minutesNum;
return `${sign}${offset} minutes`;
};
export const dateFormatFn = (
sequelize: Sequelize,
dialect: string,
field: string,
format: string,
timezone?: string,
) => {
switch (dialect) { switch (dialect) {
case 'sqlite': case 'sqlite':
format = format format = format
@ -17,6 +34,9 @@ export const dateFormatFn = (sequelize: any, dialect: string, field: string, for
.replace(/hh/g, '%H') .replace(/hh/g, '%H')
.replace(/mm/g, '%M') .replace(/mm/g, '%M')
.replace(/ss/g, '%S'); .replace(/ss/g, '%S');
if (timezone) {
return sequelize.fn('strftime', format, sequelize.col(field), getOffsetMinutesFromTimezone(timezone));
}
return sequelize.fn('strftime', format, sequelize.col(field)); return sequelize.fn('strftime', format, sequelize.col(field));
case 'mysql': case 'mysql':
case 'mariadb': case 'mariadb':
@ -27,9 +47,24 @@ export const dateFormatFn = (sequelize: any, dialect: string, field: string, for
.replace(/hh/g, '%H') .replace(/hh/g, '%H')
.replace(/mm/g, '%i') .replace(/mm/g, '%i')
.replace(/ss/g, '%S'); .replace(/ss/g, '%S');
if (timezone) {
return sequelize.fn(
'date_format',
sequelize.fn('convert_tz', sequelize.col(field), process.env.DB_TIMEZONE || '+00:00', timezone),
format,
);
}
return sequelize.fn('date_format', sequelize.col(field), format); return sequelize.fn('date_format', sequelize.col(field), format);
case 'postgres': case 'postgres':
format = format.replace(/hh/g, 'HH24').replace(/mm/g, 'MI').replace(/ss/g, 'SS'); format = format.replace(/hh/g, 'HH24').replace(/mm/g, 'MI').replace(/ss/g, 'SS');
if (timezone) {
const fieldWithTZ = sequelize.literal(
`(${sequelize
.getQueryInterface()
.quoteIdentifiers(field)} AT TIME ZONE CURRENT_SETTING('TIMEZONE') AT TIME ZONE '${timezone}')`,
);
return sequelize.fn('to_char', fieldWithTZ, format);
}
return sequelize.fn('to_char', sequelize.col(field), format); return sequelize.fn('to_char', sequelize.col(field), format);
default: default:
return sequelize.col(field); return sequelize.col(field);
@ -37,7 +72,7 @@ export const dateFormatFn = (sequelize: any, dialect: string, field: string, for
}; };
/* istanbul ignore next -- @preserve */ /* istanbul ignore next -- @preserve */
export const formatFn = (sequelize: any, dialect: string, field: string, format: string) => { export const formatFn = (sequelize: Sequelize, dialect: string, field: string, format: string) => {
switch (dialect) { switch (dialect) {
case 'sqlite': case 'sqlite':
case 'postgres': case 'postgres':
@ -47,13 +82,13 @@ export const formatFn = (sequelize: any, dialect: string, field: string, format:
} }
}; };
export const formatter = (sequelize: any, type: string, field: string, format: string) => { export const formatter = (sequelize: Sequelize, type: string, field: string, format: string, timezone?: string) => {
const dialect = sequelize.getDialect(); const dialect = sequelize.getDialect();
switch (type) { switch (type) {
case 'date': case 'date':
case 'datetime': case 'datetime':
case 'time': case 'time':
return dateFormatFn(sequelize, dialect, field, format); return dateFormatFn(sequelize, dialect, field, format, timezone);
default: default:
return formatFn(sequelize, dialect, field, format); return formatFn(sequelize, dialect, field, format);
} }

View File

@ -136,7 +136,7 @@ export const parseBuilder = async (ctx: Context, next: Next) => {
const attribute = []; const attribute = [];
const col = sequelize.col(field); const col = sequelize.col(field);
if (format) { if (format) {
attribute.push(formatter(sequelize, type, field, format)); attribute.push(formatter(sequelize, type, field, format, ctx.timezone));
} else { } else {
attribute.push(col); attribute.push(col);
} }

View File

@ -103,6 +103,32 @@ describe('multiple apps', () => {
expect(subAppStatus).toEqual('running'); expect(subAppStatus).toEqual('running');
}); });
it('should not create application named with main', async () => {
const name = 'main';
let err;
try {
await db.getRepository('applications').create({
values: {
name,
options: {
plugins: [],
},
},
context: {
waitSubAppInstall: true,
},
});
} catch (e) {
err = e;
}
expect(err).toBeDefined();
expect(await db.getRepository('applications').count()).toBe(0);
});
it('should upgrade sub app', async () => { it('should upgrade sub app', async () => {
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {

View File

@ -170,6 +170,12 @@ export class PluginMultiAppManagerServer extends Plugin {
async (model: ApplicationModel, options: Transactionable & { context?: any }) => { async (model: ApplicationModel, options: Transactionable & { context?: any }) => {
const { transaction } = options; const { transaction } = options;
const name = model.get('name') as string;
if (name === 'main') {
throw new Error('Application name "main" is reserved');
}
const subApp = model.registerToSupervisor(this.app, { const subApp = model.registerToSupervisor(this.app, {
appOptionsFactory: this.appOptionsFactory, appOptionsFactory: this.appOptionsFactory,
}); });