diff --git a/.github/workflows/nocobase-test-e2e.yml b/.github/workflows/nocobase-test-e2e.yml index 3edbbecf4e..62b4964e6d 100644 --- a/.github/workflows/nocobase-test-e2e.yml +++ b/.github/workflows/nocobase-test-e2e.yml @@ -78,4 +78,4 @@ jobs: DB_USER: nocobase DB_PASSWORD: password DB_DATABASE: nocobase - timeout-minutes: 120 + timeout-minutes: 180 diff --git a/packages/core/data-source-manager/src/__tests__/data-source-manager.test.ts b/packages/core/data-source-manager/src/__tests__/data-source-manager.test.ts index 2b40022600..9e0f2cc155 100644 --- a/packages/core/data-source-manager/src/__tests__/data-source-manager.test.ts +++ b/packages/core/data-source-manager/src/__tests__/data-source-manager.test.ts @@ -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(); + }); }); diff --git a/packages/core/data-source-manager/src/data-source-manager.ts b/packages/core/data-source-manager/src/data-source-manager.ts index 8f1fb77dea..f5d2b6fced 100644 --- a/packages/core/data-source-manager/src/data-source-manager.ts +++ b/packages/core/data-source-manager/src/data-source-manager.ts @@ -35,7 +35,6 @@ export class DataSourceManager { } } await next(); - console.log('next....'); }; } } diff --git a/packages/core/database/src/__tests__/repository/destroy.test.ts b/packages/core/database/src/__tests__/repository/destroy.test.ts index bfe8aa4fee..ca7d28ad91 100644 --- a/packages/core/database/src/__tests__/repository/destroy.test.ts +++ b/packages/core/database/src/__tests__/repository/destroy.test.ts @@ -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); + }); }); diff --git a/packages/core/database/src/__tests__/repository/update.test.ts b/packages/core/database/src/__tests__/repository/update.test.ts index 5241d8508d..8dedd3ac78 100644 --- a/packages/core/database/src/__tests__/repository/update.test.ts +++ b/packages/core/database/src/__tests__/repository/update.test.ts @@ -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: { diff --git a/packages/core/database/src/decorators/must-have-filter-decorator.ts b/packages/core/database/src/decorators/must-have-filter-decorator.ts index 5b83aea07b..77e296c792 100644 --- a/packages/core/database/src/decorators/must-have-filter-decorator.ts +++ b/packages/core/database/src/decorators/must-have-filter-decorator.ts @@ -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`); } diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index 8e2ac2527c..6e52514b8f 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -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 { + 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); + }); +}); diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index 9ec4ba12c5..9c32e8dd3a 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -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 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'] }); diff --git a/packages/core/server/src/helper.ts b/packages/core/server/src/helper.ts index cbc3f39931..c335a627d8 100644 --- a/packages/core/server/src/helper.ts +++ b/packages/core/server/src/helper.ts @@ -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' }); diff --git a/packages/core/server/src/middlewares/validate-filter-params.ts b/packages/core/server/src/middlewares/validate-filter-params.ts new file mode 100644 index 0000000000..d1bc04f7d3 --- /dev/null +++ b/packages/core/server/src/middlewares/validate-filter-params.ts @@ -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(); +} diff --git a/packages/core/test/src/server/mockServer.ts b/packages/core/test/src/server/mockServer.ts index b126d44ce5..b1a31deffe 100644 --- a/packages/core/test/src/server/mockServer.ts +++ b/packages/core/test/src/server/mockServer.ts @@ -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; diff --git a/packages/core/utils/src/__tests__/isValidFilter.test.ts b/packages/core/utils/src/__tests__/isValidFilter.test.ts new file mode 100644 index 0000000000..1af65c75e8 --- /dev/null +++ b/packages/core/utils/src/__tests__/isValidFilter.test.ts @@ -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); + }); +}); diff --git a/packages/core/utils/src/client.ts b/packages/core/utils/src/client.ts index 2cb72b9155..798b3c0e64 100644 --- a/packages/core/utils/src/client.ts +++ b/packages/core/utils/src/client.ts @@ -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'; diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index 326f9890e1..78ac553751 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -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'; diff --git a/packages/core/utils/src/isValidFilter.ts b/packages/core/utils/src/isValidFilter.ts new file mode 100644 index 0000000000..783b189b93 --- /dev/null +++ b/packages/core/utils/src/isValidFilter.ts @@ -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; + }); +} diff --git a/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/validate-filter-params.test.ts b/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/validate-filter-params.test.ts new file mode 100644 index 0000000000..1bd5ffe4b8 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/validate-filter-params.test.ts @@ -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); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/http-api/validate-update-action.test.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/http-api/validate-update-action.test.ts new file mode 100644 index 0000000000..5317419b15 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/http-api/validate-update-action.test.ts @@ -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); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/datablocks.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/datablocks.test.ts index 447c4a3d80..895c76ffe8 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/datablocks.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/datablocks.test.ts @@ -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(); diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/updateRecordForm.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/updateRecordForm.test.ts index d6c32fb6cb..f4beabf5ec 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/updateRecordForm.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/updateRecordForm.test.ts @@ -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(); diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/SchemaConfig.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/SchemaConfig.tsx index 6af0e1b923..602da4e9fc 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/SchemaConfig.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/SchemaConfig.tsx @@ -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(); -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() {
{open && schema && ( @@ -612,15 +612,46 @@ export function SchemaConfig({ value, onChange }) { ); } +function validateForms(forms: Record = {}) { + 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 ( <> - + {props.children} diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/forms/update.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/forms/update.tsx index 20443e9079..60b1d85888 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/forms/update.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/forms/update.tsx @@ -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; diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/index.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/index.ts index 6bec93a944..735d9b449a 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/index.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/index.ts @@ -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); } diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/zh-CN.json index 3d521662e3..bf527976be 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/zh-CN.json @@ -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.": "请检查您的其中的更新数据表单,并在表单设置中至少添加一个筛选条件。" } diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/form.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/form.test.ts index 1aa2fee67a..9824bc6060 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/form.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/form.test.ts @@ -596,6 +596,7 @@ describe('workflow > instructions > manual', () => { type: 'update', actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], collection: 'posts', + filter: { title: 't1' }, }, }, }, diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/destroy.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/destroy.tsx index 62c1c4dabd..4dd404d31e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/destroy.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/destroy.tsx @@ -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'; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/update.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/update.tsx index 5acbdd7165..f682ff282e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/update.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/update.tsx @@ -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 }) { diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/utils.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/utils.ts index 67ca9a2839..355888bb11 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/utils.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/utils.ts @@ -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) {