Merge branch 'main' into next

This commit is contained in:
nocobase[bot]
2026-08-01 13:57:09 +00:00
6 changed files with 410 additions and 1 deletions
@@ -82,6 +82,15 @@ export class CollectionBlockModel<T = DefaultStructure> extends DataBlockModel<T
const engine = this.context.engine as FlowEngine;
const currentVersion = this.getDirtyTrackingVersion(engine, dataSourceKey, resource, params);
if (resource instanceof MultiRecordResource && this.getDataLoadingMode() === 'manual' && !this.hasActiveFilters()) {
resource.setData([]);
resource.setMeta({ count: 0, hasNext: false });
resource.setPage(1);
resource.loading = false;
this.lastSeenDirtyVersion = currentVersion;
return;
}
if (forceRefresh) {
if (this.dirtyRefreshing) return;
this.dirtyRefreshing = true;
@@ -128,4 +128,64 @@ describe('CollectionBlockModel initial beforeRender refresh', () => {
expect(resource.getData()).toEqual([]);
expect(resource.getMeta('count')).toBe(0);
});
it('skips forced active refresh in manual mode when filters are empty', async () => {
const { model, resource } = setupModelWithManualMode();
const refreshSpy = vi.spyOn(resource, 'refresh');
resource.setData([{ id: 2, name: 'Stale' }]);
resource.setMeta({ count: 1, hasNext: true });
resource.setPage(2);
resource.loading = true;
model.onActive(true);
await Promise.resolve();
await Promise.resolve();
expect(refreshSpy).not.toHaveBeenCalled();
expect(resource.getData()).toEqual([]);
expect(resource.getMeta('count')).toBe(0);
expect(resource.getMeta('hasNext')).toBe(false);
expect(resource.getMeta('page')).toBe(1);
expect(resource.getRequestParameter('page')).toBe(1);
expect(resource.loading).toBe(false);
});
it('keeps forced active refresh in manual mode when filters are active', async () => {
const { model, resource } = setupModelWithManualMode();
const refreshSpy = vi.spyOn(resource, 'refresh').mockResolvedValue();
resource.setData([{ id: 2, name: 'Stale' }]);
resource.setMeta({ count: 1, hasNext: true, page: 2 });
model.setFilterActive('filter-form-item', true);
model.onActive(true);
await Promise.resolve();
expect(refreshSpy).toHaveBeenCalledTimes(1);
expect(resource.getData()).toEqual([{ id: 2, name: 'Stale' }]);
expect(resource.getMeta('count')).toBe(1);
});
it('skips forced active refresh in manual mode when only resource filters are active', async () => {
const { model, resource } = setupModelWithManualMode();
const refreshSpy = vi.spyOn(resource, 'refresh');
resource.setData([{ id: 2, name: 'Stale' }]);
resource.setMeta({ count: 1, hasNext: true });
resource.setPage(2);
resource.loading = true;
resource.addFilterGroup('data-scope', { status: { $eq: 'active' } });
model.onActive(true);
await Promise.resolve();
expect(refreshSpy).not.toHaveBeenCalled();
expect(resource.getData()).toEqual([]);
expect(resource.getMeta('count')).toBe(0);
expect(resource.getMeta('hasNext')).toBe(false);
expect(resource.getMeta('page')).toBe(1);
expect(resource.getRequestParameter('page')).toBe(1);
expect(resource.loading).toBe(false);
});
});
@@ -45,6 +45,34 @@ const TEMPLATE_FALLBACK_PATCH_ORIGINAL_GET_STEP_PARAMS = Symbol.for(
);
const TARGET_OWN_CONTEXT_MISSING = Symbol.for('nocobase.referenceBlockTargetOwnContextMissing');
type FilterStateTarget = {
setFilterActive?: (filterId: string, active: boolean) => void;
hasActiveFilters?: () => boolean;
removeFilterSource?: (filterId: string) => void;
getDataLoadingMode?: () => 'auto' | 'manual';
};
type PreparedFilterBlock = {
markInitialTargetRefreshHandled?: (targetId: string) => void;
};
type ReferenceFilterConfig = {
filterId?: string;
targetId?: string;
};
type ReferenceFilterManager = {
getFilterConfigs?: () => ReferenceFilterConfig[];
prepareFiltersForTarget?: (targetId: string) => Promise<Set<PreparedFilterBlock>>;
bindToTarget?: (targetId: string) => void;
};
type ReferenceFilterModel = {
context?: {
blockModel?: PreparedFilterBlock;
};
};
function isNonEmptyValue(value: unknown): boolean {
return value !== undefined && value !== null && String(value).trim() !== '';
}
@@ -233,6 +261,83 @@ export class ReferenceBlockModel extends BlockModel {
private _resolvedTargetUid?: string;
private _invalidTargetUid?: string;
private _getFilterStateTarget(): FilterStateTarget | undefined {
return this._targetModel as FilterStateTarget | undefined;
}
private _getReferenceFilterManager(): ReferenceFilterManager | undefined {
return this.context?.filterManager as ReferenceFilterManager | undefined;
}
private _markShellInitialFilterRefreshHandled() {
const filterConfigs = this._getReferenceFilterManager()?.getFilterConfigs?.();
if (!Array.isArray(filterConfigs)) {
return;
}
const filterIds = new Set(
filterConfigs
.filter((config) => config.targetId === this.uid && typeof config.filterId === 'string' && config.filterId)
.map((config) => config.filterId as string),
);
filterIds.forEach((filterId) => {
try {
const filterModel = this.flowEngine?.getModel?.(filterId) as ReferenceFilterModel | undefined;
filterModel?.context?.blockModel?.markInitialTargetRefreshHandled?.(this.uid);
} catch (_) {
// ignore
}
});
}
private _createTargetFilterManager(target: FlowModel): ReferenceFilterManager | undefined {
const filterManager = this._getReferenceFilterManager();
if (!filterManager) {
return undefined;
}
const getTargetIdForFilterManager = (targetId: string) => (targetId === target.uid ? this.uid : targetId);
return new Proxy(filterManager, {
get: (source, prop) => {
if (prop === 'prepareFiltersForTarget') {
if (typeof source.prepareFiltersForTarget !== 'function') {
return undefined;
}
return (targetId: string) => source.prepareFiltersForTarget?.(getTargetIdForFilterManager(targetId));
}
if (prop === 'bindToTarget') {
if (typeof source.bindToTarget !== 'function') {
return undefined;
}
return (targetId: string) => source.bindToTarget?.(getTargetIdForFilterManager(targetId));
}
const value = Reflect.get(source, prop, source);
return typeof value === 'function' ? value.bind(source) : value;
},
});
}
setFilterActive(filterId: string, active: boolean) {
super.setFilterActive(filterId, active);
const target = this._getFilterStateTarget();
target?.setFilterActive?.(filterId, active);
}
hasActiveFilters(): boolean {
return super.hasActiveFilters() || this._getFilterStateTarget()?.hasActiveFilters?.() === true;
}
removeFilterSource(filterId: string) {
super.removeFilterSource(filterId);
this._getFilterStateTarget()?.removeFilterSource?.(filterId);
}
getDataLoadingMode(): 'auto' | 'manual' {
return this._getFilterStateTarget()?.getDataLoadingMode?.() || super.getDataLoadingMode();
}
private _restoreTemplateFallbackPatch(target?: FlowModel) {
if (!target) return;
const original = (target as any)[TEMPLATE_FALLBACK_PATCH_ORIGINAL_GET_STEP_PARAMS] as
@@ -417,6 +522,10 @@ export class ReferenceBlockModel extends BlockModel {
const bridge = new FlowContext();
bridge.defineProperty('engine', { value: engine });
bridge.defineProperty('filterManager', {
cache: false,
get: () => this._createTargetFilterManager(target),
});
bridge.addDelegate(this.context as any);
target.context.addDelegate(bridge);
targetContext[TARGET_CONTEXT_BRIDGE_MARKER] = true;
@@ -438,6 +547,19 @@ export class ReferenceBlockModel extends BlockModel {
});
}
private async _bindReferenceFiltersToResolvedTarget() {
const filterManager = this._getReferenceFilterManager();
if (!filterManager?.prepareFiltersForTarget && !filterManager?.bindToTarget) {
return;
}
const preparedFilterBlocks = await filterManager.prepareFiltersForTarget?.(this.uid);
filterManager.bindToTarget?.(this.uid);
preparedFilterBlocks?.forEach((filterBlock) => {
filterBlock?.markInitialTargetRefreshHandled?.(this.uid);
});
}
get title() {
return this._targetModel?.title || super.title;
}
@@ -500,6 +622,7 @@ export class ReferenceBlockModel extends BlockModel {
},
});
});
this._markShellInitialFilterRefreshHandled();
}
// 让 `ctx.model.setProps/getProps` 在引用区块场景下也作用到目标模型
@@ -585,6 +708,7 @@ export class ReferenceBlockModel extends BlockModel {
public async onDispatchEventStart(eventName: string): Promise<void> {
if (eventName !== 'beforeRender') return;
this._markShellInitialFilterRefreshHandled();
const stepParams = (this.getStepParams as any)?.('referenceSettings', 'target') || {};
const targetUid = (stepParams?.targetUid || '').trim() || undefined;
if (!targetUid) {
@@ -674,6 +798,7 @@ export class ReferenceBlockModel extends BlockModel {
// 关键:让 ctx.model.props.xxx 的写法在引用区块中也能作用到目标区块
// - beforeRender 的 flows 会在 onDispatchEventStart 之后执行,因此这里同步可以保证事件流拿到的是目标 props
this.props = target.props;
await this._bindReferenceFiltersToResolvedTarget();
}
async destroy(): Promise<boolean> {
@@ -9,6 +9,7 @@
import { FlowEngine, FlowModel, MultiRecordResource, SingleRecordResource } from '@nocobase/flow-engine';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { CollectionBlockModel, FilterManager } from '@nocobase/client-v2';
import { ReferenceBlockModel } from '../ReferenceBlockModel';
class MockGridModel extends FlowModel {}
@@ -84,6 +85,28 @@ MockInteractiveBlockModel.registerEvents({
class DetailsBlockModel extends FlowModel {}
class EditFormModel extends FlowModel {}
class ReferenceFilterTargetBlockModel extends CollectionBlockModel {
createResource(ctx: any) {
return ctx.createResource(MultiRecordResource);
}
}
class MockFilterFormItemModel extends FlowModel {
onInit(options: any) {
super.onInit(options);
this.context.defineProperty('blockModel', {
value: options.blockModel,
});
this.context.defineProperty('filterField', {
value: { name: 'nickname', type: 'string', interface: 'input' },
});
}
getFilterValue() {
return '111';
}
}
const TEST_TIMEOUT = 10_000;
describe('ReferenceBlockModel', () => {
@@ -203,6 +226,8 @@ describe('ReferenceBlockModel', () => {
InteractiveBlockModel: MockInteractiveBlockModel,
DetailsBlockModel,
EditFormModel,
ReferenceFilterTargetBlockModel,
MockFilterFormItemModel,
ReferenceBlockModel,
});
scopedEngine.registerModels({
@@ -213,6 +238,8 @@ describe('ReferenceBlockModel', () => {
InteractiveBlockModel: MockInteractiveBlockModel,
DetailsBlockModel,
EditFormModel,
ReferenceFilterTargetBlockModel,
MockFilterFormItemModel,
ReferenceBlockModel,
});
@@ -452,6 +479,144 @@ describe('ReferenceBlockModel', () => {
);
});
describe('Filter linkage', () => {
it('marks shell-target initial refresh as handled before target resolution', () => {
const markInitialTargetRefreshHandled = vi.fn();
engine.createModel({
uid: 'filter-form-item-uid',
use: 'MockFilterFormItemModel',
blockModel: {
prepareInitialFilterValues: vi.fn().mockResolvedValue(true),
markInitialTargetRefreshHandled,
},
});
const filterManager = new FilterManager(gridModel as any, [
{
filterId: 'filter-form-item-uid',
targetId: 'reference-block-uid',
filterPaths: ['nickname'],
},
]);
gridModel.context.defineProperty('filterManager', {
value: filterManager,
});
referenceBlockModel = engine.createModel({
uid: 'reference-block-uid',
use: 'ReferenceBlockModel',
parentId: 'grid-uid',
subKey: 'items',
subType: 'array',
}) as ReferenceBlockModel;
gridModel.addSubModel('items', referenceBlockModel);
expect(markInitialTargetRefreshHandled).toHaveBeenCalledWith('reference-block-uid');
});
it(
'binds shell-target filters to the resolved target before its initial manual refresh',
async () => {
const usersCollection = {
name: 'users',
filterTargetKey: 'id',
fields: [
{ name: 'id', type: 'integer', interface: 'number' },
{ name: 'nickname', type: 'string', interface: 'input' },
],
};
engine.context.dataSourceManager.getDataSource('main').addCollection(usersCollection);
scopedEngine.context.dataSourceManager.getDataSource('main').addCollection(usersCollection);
const prepareInitialFilterValues = vi.fn().mockResolvedValue(true);
engine.createModel({
uid: 'filter-form-item-uid',
use: 'MockFilterFormItemModel',
blockModel: {
prepareInitialFilterValues,
markInitialTargetRefreshHandled: vi.fn(),
},
});
const filterManager = new FilterManager(gridModel as any, [
{
filterId: 'filter-form-item-uid',
targetId: 'reference-block-uid',
filterPaths: ['nickname'],
},
]);
gridModel.context.defineProperty('filterManager', {
value: filterManager,
});
store['reference-filter-target-uid'] = {
uid: 'reference-filter-target-uid',
use: 'ReferenceFilterTargetBlockModel',
parentId: 'grid-uid',
subKey: 'items',
subType: 'array',
stepParams: {
resourceSettings: {
init: {
dataSourceKey: 'main',
collectionName: 'users',
},
},
dataLoadingModeSettings: {
dataLoadingMode: {
mode: 'manual',
},
},
},
};
referenceBlockModel = engine.createModel({
uid: 'reference-block-uid',
use: 'ReferenceBlockModel',
parentId: 'grid-uid',
subKey: 'items',
subType: 'array',
stepParams: {
referenceSettings: {
target: {
targetUid: 'reference-filter-target-uid',
mode: 'reference',
},
},
},
}) as ReferenceBlockModel;
gridModel.addSubModel('items', referenceBlockModel);
await referenceBlockModel.dispatchEvent('beforeRender', undefined, { useCache: false });
const target = (referenceBlockModel as any)._targetModel as ReferenceFilterTargetBlockModel;
expect(target).toBeDefined();
expect(prepareInitialFilterValues).toHaveBeenCalledTimes(1);
expect(target.hasActiveFilters()).toBe(true);
expect(target.resource.getRequestParameter('filter')).toContain('111');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
target.context.filterManager.bindToTarget(target.uid);
const targetNotFoundCalls = consoleErrorSpy.mock.calls.filter(([message]) =>
String(message).includes(`Target model with uid "${target.uid}" not found`),
);
consoleErrorSpy.mockRestore();
expect(targetNotFoundCalls).toEqual([]);
const runActionSpy = vi.spyOn(target.resource, 'runAction').mockResolvedValue({
data: [{ id: 1, nickname: '111' }],
meta: { count: 1, hasNext: false, page: 1, pageSize: 20 },
});
await target.dispatchEvent('beforeRender', undefined, { useCache: false });
expect(runActionSpy).toHaveBeenCalledTimes(1);
expect(target.resource.getData()).toEqual([{ id: 1, nickname: '111' }]);
},
TEST_TIMEOUT,
);
});
describe('Template option disabled rules', () => {
it('disables template when associationName mismatches in association context', async () => {
referenceBlockModel = engine.createModel({
@@ -268,6 +268,56 @@ describe('workflow > actions > workflows', () => {
});
});
describe('execute', () => {
it('does not create a revision when autoRevision is string zero', async () => {
const workflow = await WorkflowModel.create({
type: 'collection',
sync: true,
config: {
mode: 1,
collection: 'posts',
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
const { body, status } = await agent.resource('workflows').execute({
filterByTk: workflow.id,
autoRevision: '0',
values: {
data: post.toJSON(),
},
});
expect(status).toBe(200);
expect(body.data).not.toHaveProperty('newVersionId');
await expect(WorkflowRepo.count({ filter: { key: workflow.key } })).resolves.toBe(1);
});
it('creates a revision when autoRevision is string one', async () => {
const workflow = await WorkflowModel.create({
type: 'collection',
sync: true,
config: {
mode: 1,
collection: 'posts',
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
const { body, status } = await agent.resource('workflows').execute({
filterByTk: workflow.id,
autoRevision: '1',
values: {
data: post.toJSON(),
},
});
expect(status).toBe(200);
expect(body.data.newVersionId).toBeDefined();
await expect(WorkflowRepo.count({ filter: { key: workflow.key } })).resolves.toBe(2);
});
});
describe('destroy', () => {
it('cascading destroy all revisions, nodes, but not executions and jobs', async () => {
const workflow = await WorkflowModel.create({
@@ -228,7 +228,7 @@ export async function execute(context: Context, next) {
filter: { key: workflow.key },
});
let newVersion;
if (executed == 0 && autoRevision) {
if (executed === 0 && Number(autoRevision) === 1) {
newVersion = await repository.revision({
filterByTk: workflow.id,
filter: { key: workflow.key },