fix: reject update when filter is empty object (#3777)

* fix: reject update when filter is empty object

* chore: valid filter when destroy data

* fix: test

* refactor(utils): move isValidFilter to utils

* chore: test

* chore: test

* chore: test

* fix(plugin-workflow-manual): fix test case

* fix(plugin-workflow-manual): add filter check for update form in manual node

* chore: validate filter params as middleware

* chore: action filter validate in data-source-manager

* chore: acl filter params validate test

* chore: move validate filter params middleware into core

* Update nocobase-test-e2e.yml

* chore: only run workflow's tests

* chore: only run workflow's tests

* fix: updateRecordForm

* Revert "chore: only run workflow's tests"

This reverts commit 64ce1241718ef516ff9bf7245296dee963ab2e43.

* Revert "chore: only run workflow's tests"

This reverts commit b9057b35ec4f87ba13c08650ffc7919e32eb3fc3.

---------

Co-authored-by: mytharcher <mytharcher@gmail.com>
Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: Zeke Zhang <958414905@qq.com>
Co-authored-by: hongboji <j414562100@qq.com>
This commit is contained in:
ChengLei Shao 2024-03-30 21:50:54 +08:00 committed by GitHub
parent b1aa6cff5e
commit 89733247bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 773 additions and 43 deletions

View File

@ -78,4 +78,4 @@ jobs:
DB_USER: nocobase
DB_PASSWORD: password
DB_DATABASE: nocobase
timeout-minutes: 120
timeout-minutes: 180

View File

@ -61,4 +61,96 @@ describe('example', () => {
expect(m.name).toBe('n1');
await app.destroy();
});
it('should validate filter params in update actions', async () => {
const app = await createMockServer({
acl: false,
resourcer: {
prefix: '/api/',
},
name: 'update-filter',
});
const database = mockDatabase({
tablePrefix: 'ds1_',
});
await database.clean({ drop: true });
const ds1 = new SequelizeDataSource({
name: 'ds1',
resourceManager: {},
collectionManager: {
database,
},
});
ds1.collectionManager.defineCollection({
name: 'test1',
fields: [{ type: 'string', name: 'name' }],
});
await ds1.collectionManager.sync();
ds1.acl.allow('test1', 'update', 'public');
await app.dataSourceManager.add(ds1);
const res = await app
.agent()
.post(`/api/test1:update?filter=${JSON.stringify({})}`)
.set('x-data-source', 'ds1')
.auth('abc', { type: 'bearer' })
.set('X-Authenticator', 'basic');
expect(res.status).toBe(500);
await app.destroy();
});
it('should validate filter params in destroy actions', async () => {
const app = await createMockServer({
acl: false,
resourcer: {
prefix: '/api/',
},
name: 'update-filter',
});
const database = mockDatabase({
tablePrefix: 'ds1_',
});
await database.clean({ drop: true });
const ds1 = new SequelizeDataSource({
name: 'ds1',
resourceManager: {},
collectionManager: {
database,
},
});
ds1.collectionManager.defineCollection({
name: 'test1',
fields: [{ type: 'string', name: 'name' }],
});
await ds1.collectionManager.sync();
ds1.acl.allow('test1', 'destroy', 'public');
await app.dataSourceManager.add(ds1);
const res = await app
.agent()
.post(`/api/test1:destroy?filter=${JSON.stringify({})}`)
.set('x-data-source', 'ds1')
.auth('abc', { type: 'bearer' })
.set('X-Authenticator', 'basic');
expect(res.status).toBe(500);
await app.destroy();
});
});

View File

@ -35,7 +35,6 @@ export class DataSourceManager {
}
}
await next();
console.log('next....');
};
}
}

View File

@ -314,4 +314,63 @@ describe('destroy', () => {
await User.repository.destroy(u2['id']);
expect(await User.repository.count()).toEqual(2);
});
it('should not destroy data when filter is empty', async () => {
await User.repository.createMany({
records: [
{
name: 'u1',
},
{
name: 'u3',
},
{
name: 'u2',
},
],
});
let err;
try {
await User.repository.destroy({
filter: {},
});
} catch (e) {
err = e;
}
expect(await User.repository.count()).toBe(3);
});
it('should not destroy data when filter is not valid', async () => {
await User.repository.createMany({
records: [
{
name: 'u1',
},
{
name: 'u3',
},
{
name: 'u2',
},
],
});
let err;
try {
await User.repository.destroy({
filter: {
$and: [],
$or: [],
},
});
} catch (e) {
err = e;
}
expect(await User.repository.count()).toBe(3);
});
});

View File

@ -202,6 +202,67 @@ describe('update', () => {
expect(p1.toJSON()['tags']).toEqual([]);
});
it('should not update items when filter is empty', async () => {
await db.getRepository('posts').create({
values: [
{
title: 'p1',
},
{
title: 'p2',
},
],
});
let err;
try {
await db.getRepository('posts').update({
values: {
title: 'p3',
},
filter: {},
});
} catch (e) {
err = e;
}
expect(err).toBeDefined();
expect(err.message).toContain('must provide filter or filterByTk for update call');
});
it('should not update items when filter is not a valid filter object', async () => {
await db.getRepository('posts').create({
values: [
{
title: 'p1',
},
{
title: 'p2',
},
],
});
let err;
try {
await db.getRepository('posts').update({
values: {
title: 'p3',
},
filter: {
$and: [],
$or: [],
},
});
} catch (e) {
err = e;
}
expect(err).toBeDefined();
expect(err.message).toContain('must provide filter or filterByTk for update call');
});
it('should not update items without filter or filterByPk', async () => {
await db.getRepository('posts').create({
values: {

View File

@ -1,3 +1,5 @@
import { isValidFilter } from '@nocobase/utils';
const mustHaveFilter = () => (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
const oldValue = descriptor.value;
@ -8,7 +10,7 @@ const mustHaveFilter = () => (target: any, propertyKey: string, descriptor: Prop
return oldValue.apply(this, args);
}
if (!options?.filter && !options?.filterByTk && !options?.forceUpdate) {
if (!isValidFilter(options?.filter) && !options?.filterByTk && !options?.forceUpdate) {
throw new Error(`must provide filter or filterByTk for ${propertyKey} call, or set forceUpdate to true`);
}

View File

@ -15,6 +15,8 @@ import {
UpdateOptions as SequelizeUpdateOptions,
WhereOperators,
} from 'sequelize';
import { isValidFilter } from '@nocobase/utils';
import { Collection } from './collection';
import { Database } from './database';
import mustHaveFilter from './decorators/must-have-filter-decorator';
@ -718,7 +720,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
});
}
if (options.filter) {
if (options.filter && isValidFilter(options.filter)) {
if (
this.collection.model.primaryKeyAttributes.length !== 1 &&
!lodash.get(this.collection.options, 'filterTargetKey')

View File

@ -200,6 +200,7 @@ export function parseQuery(input: string): any {
// 逗号分隔转换为数组
// comma: true,
});
// filter 支持 json string
if (typeof query.filter === 'string') {
query.filter = JSON.parse(query.filter);

View File

@ -0,0 +1,43 @@
import supertest from 'supertest';
import { Application } from '../application';
describe('i18next', () => {
let app: Application;
let agent: supertest.SuperAgentTest;
beforeEach(() => {
app = new Application({
database: {
dialect: 'sqlite',
storage: ':memory:',
},
resourcer: {
prefix: '/api',
},
acl: false,
dataWrapping: false,
registerActions: false,
});
agent = supertest.agent(app.callback());
});
afterEach(async () => {
return app.destroy();
});
it('should validate filter params when request update', async () => {
app.resource({
name: 'tests',
actions: {
update: async (ctx, next) => {
ctx.body = 'ok';
await next();
},
},
});
const updateRes = await agent.post(`/api/tests:update?filter=${JSON.stringify({})}`);
expect(updateRes.status).toBe(500);
});
});

View File

@ -48,6 +48,7 @@ import { InstallOptions, PluginManager } from './plugin-manager';
import { DataSourceManager, SequelizeDataSource } from '@nocobase/data-source-manager';
import packageJson from '../package.json';
import { MainDataSource } from './main-data-source';
import validateFilterParams from './middlewares/validate-filter-params';
export type PluginType = string | typeof Plugin;
export type PluginConfiguration = PluginType | [PluginType, any];
@ -1070,7 +1071,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
});
this._dataSourceManager.use(this._authManager.middleware(), { tag: 'auth' });
this._dataSourceManager.use(validateFilterParams, { tag: 'validate-filter-params', before: ['auth'] });
this.resourcer.use(this._authManager.middleware(), { tag: 'auth' });
this.resourcer.use(validateFilterParams, { tag: 'validate-filter-params', before: ['auth'] });
if (this.options.acl !== false) {
this.resourcer.use(this.acl.middleware(), { tag: 'acl', after: ['auth'] });

View File

@ -85,6 +85,7 @@ export function registerMiddlewares(app: Application, options: ApplicationOption
tag: 'parseVariables',
after: 'acl',
});
app.resourcer.use(dateTemplate, { tag: 'dateTemplate', after: 'acl' });
app.use(db2resource, { tag: 'db2resource', after: 'dataWrapping' });

View File

@ -0,0 +1,12 @@
import { isValidFilter } from '@nocobase/utils';
export default async function validateFilterParams(ctx, next) {
const { params } = ctx.action;
const guardedActions = ['update', 'destroy'];
if (params.filter && !isValidFilter(params.filter) && guardedActions.includes(params.actionName)) {
throw new Error(`Invalid filter: ${JSON.stringify(params.filter)}`);
}
await next();
}

View File

@ -152,6 +152,10 @@ export class MockServer extends Application {
url += `/${filterByTk}`;
}
if (restParams.filter) {
restParams.filter = JSON.stringify(restParams.filter);
}
const queryString = qs.stringify(restParams, { arrayFormat: 'brackets' });
let request;

View File

@ -0,0 +1,23 @@
import { isValidFilter } from '..';
describe('isValidFilter', () => {
it('should return false', () => {
expect(isValidFilter(undefined)).toBe(false);
expect(isValidFilter({})).toBe(false);
expect(isValidFilter({ $and: [] })).toBe(false);
expect(isValidFilter({ $or: [] })).toBe(false);
expect(isValidFilter({ $and: [{}] })).toBe(false);
expect(isValidFilter({ $or: [{}] })).toBe(false);
expect(isValidFilter({ $and: [{ $or: [] }] })).toBe(false);
expect(isValidFilter({ $or: [{ $and: [] }] })).toBe(false);
expect(isValidFilter({ $and: [{}], $or: [{ $and: [], $or: [] }] })).toBe(false);
});
it('should return true', () => {
expect(isValidFilter({ $and: [{ name: { $eq: 'test' } }] })).toBe(true);
expect(isValidFilter({ $or: [{ name: { $eq: 'test' } }] })).toBe(true);
expect(isValidFilter({ $and: [{ $or: [{ name: { $eq: 'test' } }] }] })).toBe(true);
expect(isValidFilter({ $or: [{ $and: [{ name: { $eq: 'test' } }] }] })).toBe(true);
expect(isValidFilter({ $and: [], $or: [{ name: 'test' }] })).toBe(true);
});
});

View File

@ -6,6 +6,7 @@ export * from './common';
export * from './date';
export * from './forEach';
export * from './getValuesByPath';
export * from './isValidFilter';
export * from './json-templates';
export * from './log';
export * from './merge';

View File

@ -8,6 +8,7 @@ export * from './date';
export * from './dayjs';
export * from './forEach';
export * from './fs-exists';
export * from './isValidFilter';
export * from './json-templates';
export * from './koa-multer';
export * from './measure-execution-time';

View File

@ -0,0 +1,33 @@
export function isValidFilter(condition: any) {
if (!condition) {
return false;
}
const groups = [condition.$and, condition.$or].filter(Boolean);
if (groups.length == 0) {
return Object.keys(condition).length > 0;
}
return groups.some((item) => {
if (Array.isArray(item)) {
return item.some(isValidFilter);
}
if (item.$and || item.$or) {
return isValidFilter(item);
}
const [name] = Object.keys(item);
if (!name || !item[name]) {
return false;
}
const [op] = Object.keys(item[name]);
if (!op || typeof item[name][op] === 'undefined') {
return false;
}
return true;
});
}

View File

@ -0,0 +1,119 @@
import { MockServer } from '@nocobase/test';
import { Database } from '@nocobase/database';
import { ACL } from '@nocobase/acl';
import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage';
import { prepareApp } from './prepare';
describe('acl', () => {
let app: MockServer;
let db: Database;
let acl: ACL;
let admin;
let adminAgent;
let userPlugin;
let uiSchemaRepository: UiSchemaRepository;
afterEach(async () => {
await app.destroy();
});
beforeEach(async () => {
app = await prepareApp();
db = app.db;
acl = app.acl;
const UserRepo = db.getCollection('users').repository;
admin = await UserRepo.create({
values: {
roles: ['admin'],
},
});
adminAgent = app.agent().login(admin);
uiSchemaRepository = db.getRepository('uiSchemas');
});
it('should throw error when filter is empty during update resource', async () => {
await db.getRepository('collections').create({
context: {},
values: {
name: 'posts',
fields: [
{
type: 'string',
name: 'title',
},
{
type: 'boolean',
name: 'published',
defaultValue: true,
},
],
},
});
await db.getRepository('posts').create({
values: {
title: 'old title',
},
});
const response = await adminAgent.resource('dataSourcesRolesResourcesScopes').create({
values: {
resourceName: 'posts',
name: 'published posts',
scope: {
published: true,
},
},
});
expect(response.statusCode).toEqual(200);
const scope = await db.getRepository('dataSourcesRolesResourcesScopes').findOne({
filter: {
name: 'published posts',
},
});
expect(scope.get('scope')).toMatchObject({
published: true,
});
// assign scope to admin role
const createResp = await adminAgent.resource('roles.resources', 'admin').create({
values: {
name: 'posts',
usingActionsConfig: true,
actions: [
{
name: 'update',
scope: scope.id,
},
],
},
});
expect(createResp.statusCode).toEqual(200);
const updateResp = await adminAgent.resource('posts').update({
filter: {},
values: {
title: 'new title',
},
});
expect(updateResp.statusCode).not.toBe(200);
expect(
await db.getRepository('posts').count({
filter: {
title: 'new title',
},
}),
).toBe(0);
});
});

View File

@ -0,0 +1,97 @@
import { Database } from '@nocobase/database';
import { MockServer } from '@nocobase/test';
import { createApp } from '../index';
describe('action test', () => {
let db: Database;
let app: MockServer;
beforeEach(async () => {
app = await createApp();
db = app.db;
});
afterEach(async () => {
await app.destroy();
});
it('should validate update action filter params', async () => {
await db.getRepository('collections').create({
values: {
name: 'posts',
fields: [
{
name: 'title',
type: 'string',
},
],
},
context: {},
});
await db.getRepository('posts').create({
values: [
{
title: 'p1',
},
{
title: 'p2',
},
],
});
const resp = await app
.agent()
.resource('posts')
.update({
filter: {},
values: {
title: 'p3',
},
});
expect(resp.status).toBe(500);
expect(
await db.getRepository('posts').count({
filter: {
title: 'p3',
},
}),
).toEqual(0);
});
it('should validate destroy action filter params', async () => {
await db.getRepository('collections').create({
values: {
name: 'posts',
fields: [
{
name: 'title',
type: 'string',
},
],
},
context: {},
});
await db.getRepository('posts').create({
values: [
{
title: 'p1',
},
{
title: 'p2',
},
],
});
const resp = await app.agent().resource('posts').destroy({
filter: {},
});
expect(resp.status).toBe(500);
expect(await db.getRepository('posts').count({})).toEqual(2);
});
});

View File

@ -1101,6 +1101,19 @@ test.describe('field data', () => {
await preManualNodePom.updateRecordFormMenu.hover();
await page.getByRole('menuitem', { name: preManualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${preManualNodeCollectionName}"]`)
.hover();
await page
.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${preManualNodeCollectionName}`)
.click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${preManualNodeCollectionName}"]`)
.hover();

View File

@ -104,6 +104,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -260,6 +271,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -416,6 +438,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -572,6 +605,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -728,6 +772,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -884,6 +939,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -1056,6 +1122,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -1228,6 +1305,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -1400,6 +1488,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -1573,6 +1672,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -1754,6 +1864,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -1926,6 +2047,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
@ -2104,6 +2236,17 @@ test.describe('field data update', () => {
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();
await page.getByLabel(`designer-schema-settings-CardItem-UpdateFormDesigner-${manualNodeCollectionName}`).click();
await page.getByRole('menuitem', { name: 'Filter settings' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'exists', exact: true }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page
.locator(`button[aria-label^="schema-initializer-Grid-form:configureFields-${manualNodeCollectionName}"]`)
.hover();

View File

@ -1,7 +1,7 @@
import { FormLayout } from '@formily/antd-v5';
import { createForm } from '@formily/core';
import { FormProvider, ISchema, Schema, useFieldSchema, useForm } from '@formily/react';
import { Alert, Button, Modal, Space } from 'antd';
import { Alert, Button, Modal, Space, message } from 'antd';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -45,7 +45,7 @@ import WorkflowPlugin, {
} from '@nocobase/plugin-workflow/client';
import { Registry, lodash } from '@nocobase/utils/client';
import { NAMESPACE, useLang } from '../../locale';
import { NAMESPACE, usePluginTranslation } from '../../locale';
import { FormBlockProvider } from './FormBlockProvider';
import createRecordForm from './forms/create';
import customRecordForm from './forms/custom';
@ -86,13 +86,14 @@ export type ManualFormType = {
[key: string]: React.FC;
};
};
validate?: (config: any) => string | null;
};
export const manualFormTypes = new Registry<ManualFormType>();
manualFormTypes.register('customForm', customRecordForm);
manualFormTypes.register('createForm', createRecordForm);
manualFormTypes.register('updateForm', updateRecordForm);
manualFormTypes.register('custom', customRecordForm);
manualFormTypes.register('create', createRecordForm);
manualFormTypes.register('update', updateRecordForm);
function useTriggerInitializers(): SchemaInitializerItemType | null {
const { workflow } = useFlowContext();
@ -243,7 +244,8 @@ export const addBlockButton = new CompatibleSchemaInitializer(
function AssignedFieldValues() {
const ctx = useContext(SchemaComponentContext);
const { t } = useTranslation();
const { t: coreT } = useTranslation();
const { t } = usePluginTranslation();
const fieldSchema = useFieldSchema();
const scope = useWorkflowVariableOptions();
const [open, setOpen] = useState(false);
@ -275,7 +277,7 @@ function AssignedFieldValues() {
}, [fieldSchema]);
const upLevelActiveFields = useFormActiveFields();
const title = t('Assign field values');
const title = coreT('Assign field values');
function onCancel() {
setOpen(false);
@ -321,9 +323,7 @@ function AssignedFieldValues() {
<FormProvider form={form}>
<FormLayout layout={'vertical'}>
<Alert
message={useLang(
'Values preset in this form will override user submitted ones when continue or reject.',
)}
message={t('Values preset in this form will override user submitted ones when continue or reject.')}
/>
<br />
{open && schema && (
@ -612,15 +612,46 @@ export function SchemaConfig({ value, onChange }) {
);
}
function validateForms(forms: Record<string, any> = {}) {
for (const form of Object.values(forms)) {
const formType = manualFormTypes.get(form.type);
if (typeof formType.validate === 'function') {
const msg = formType.validate(form);
if (msg) {
return msg;
}
}
}
}
export function SchemaConfigButton(props) {
const { workflow } = useFlowContext();
const [visible, setVisible] = useState(false);
const { values } = useForm();
const { t } = usePluginTranslation();
const onSetVisible = useCallback(
(v) => {
if (!v) {
const msg = validateForms(values.forms);
if (msg) {
message.error({
// eslint-disable-next-line react-hooks/rules-of-hooks
title: t('Validation failed'),
content: t(msg),
});
return;
}
}
setVisible(v);
},
[values.forms],
);
return (
<>
<Button type="primary" onClick={() => setVisible(true)} disabled={false}>
{useLang(workflow.executed ? 'View user interface' : 'Configure user interface')}
{t(workflow.executed ? 'View user interface' : 'Configure user interface')}
</Button>
<ActionContextProvider value={{ visible, setVisible, formValueChanged: false }}>
<ActionContextProvider value={{ visible, setVisible: onSetVisible, formValueChanged: false }}>
{props.children}
</ActionContextProvider>
</>

View File

@ -15,6 +15,7 @@ import {
useDesignable,
useMenuSearch,
} from '@nocobase/client';
import { isValidFilter } from '@nocobase/utils/client';
import { FilterDynamicComponent } from '@nocobase/plugin-workflow/client';
import { NAMESPACE } from '../../../locale';
@ -156,4 +157,11 @@ export default {
},
components: {},
},
validate({ filter }) {
if (!filter || !isValidFilter(filter)) {
return 'Please check one of your update record form, and add at least one filter condition in form settings.';
}
return null;
},
} as ManualFormType;

View File

@ -7,6 +7,8 @@ export function useLang(key: string, options = {}) {
return t(key);
}
export function usePluginTranslation(options) {
export const lang = useLang;
export function usePluginTranslation(options?) {
return useTranslation(NAMESPACE, options);
}

View File

@ -27,5 +27,6 @@
"Filter settings": "筛选设置",
"Workflow todos": "工作流待办",
"Task node": "任务节点",
"Unprocessed": "未处理"
"Unprocessed": "未处理",
"Please check one of your update record form, and add at least one filter condition in form settings.": "请检查您的其中的更新数据表单,并在表单设置中至少添加一个筛选条件。"
}

View File

@ -596,6 +596,7 @@ describe('workflow > instructions > manual', () => {
type: 'update',
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
collection: 'posts',
filter: { title: 't1' },
},
},
},

View File

@ -1,8 +1,8 @@
import { useCollectionDataSource } from '@nocobase/client';
import { isValidFilter } from '@nocobase/utils/client';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import { collection, filter } from '../schemas/collection';
import { isValidFilter } from '../utils';
import { Instruction } from '.';
import { NAMESPACE, lang } from '../locale';

View File

@ -2,6 +2,7 @@ import { useField, useForm } from '@formily/react';
import React from 'react';
import { useCollectionDataSource, useCollectionManager_deprecated } from '@nocobase/client';
import { isValidFilter } from '@nocobase/utils/client';
import CollectionFieldset from '../components/CollectionFieldset';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
@ -9,7 +10,6 @@ import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import { RadioWithTooltip } from '../components/RadioWithTooltip';
import { NAMESPACE, lang } from '../locale';
import { collection, filter, values } from '../schemas/collection';
import { isValidFilter } from '../utils';
import { Instruction } from '.';
function IndividualHooksRadioWithTooltip({ onChange, ...props }) {

View File

@ -12,29 +12,6 @@ export function linkNodes(nodes): void {
}
}
export function isValidFilter(condition) {
const group = condition.$and || condition.$or;
if (!group) {
return false;
}
return group.some((item) => {
if (item.$and || item.$or) {
return isValidFilter(item);
}
const [name] = Object.keys(item);
if (!name || !item[name]) {
return false;
}
const [op] = Object.keys(item[name]);
if (!op || typeof item[name][op] === 'undefined') {
return false;
}
return true;
});
}
export function traverseSchema(schema, fn) {
fn(schema);
if (schema.properties) {